diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e001dc3e9..a5002fec5 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -56,7 +56,7 @@ jobs: run: corepack enable && corepack prepare pnpm@latest --activate - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -79,7 +79,7 @@ jobs: run: git tag "${{ steps.version.outputs.version }}" - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser version: ~> v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19c8e5404..2ce341770 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,7 +74,7 @@ jobs: run: corepack enable && corepack prepare pnpm@latest --activate - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -94,7 +94,7 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser version: ~> v2 diff --git a/README.fr.md b/README.fr.md index 325c6c096..cbaffc2d1 100644 --- a/README.fr.md +++ b/README.fr.md @@ -18,7 +18,7 @@ Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) | **Français** +[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md) @@ -105,6 +105,8 @@ _*Les versions récentes peuvent utiliser 10–20 Mo en raison des fusions rapid PicoClaw +> 📋 **[Liste de Compatibilité Matérielle](docs/hardware-compatibility.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR ! + ## 🦾 Démonstration ### 🛠️ Flux de Travail Standard de l'Assistant @@ -139,7 +141,7 @@ Donnez une seconde vie à votre téléphone d'il y a dix ans ! Transformez-le en 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 +termux-chroot ./picoclaw onboard # chroot fournit une disposition standard du système de fichiers Linux ``` Puis suivez les instructions de la section « Démarrage Rapide » pour terminer la configuration ! @@ -160,11 +162,15 @@ PicoClaw peut être déployé sur pratiquement n'importe quel appareil Linux ! ## 📦 Installation -### Installer avec un binaire précompilé +### Télécharger depuis picoclaw.io (Recommandé) -Téléchargez le binaire pour votre plateforme depuis la page des [Releases](https://github.com/sipeed/picoclaw/releases). +Visitez **[picoclaw.io](https://picoclaw.io)** — le site officiel détecte automatiquement votre plateforme et propose un téléchargement en un clic. Pas besoin de choisir manuellement une architecture. -### Installer depuis les sources (dernières fonctionnalités, recommandé pour le développement) +### Télécharger le binaire précompilé + +Vous pouvez aussi télécharger le binaire pour votre plateforme depuis la page [GitHub Releases](https://github.com/sipeed/picoclaw/releases). + +### Compiler depuis les sources (pour le développement) ```bash git clone https://github.com/sipeed/picoclaw.git @@ -200,6 +206,7 @@ Pour des guides détaillés, consultez la documentation ci-dessous. Ce README ne | 🔄 [Spawn & Tâches Asynchrones](docs/fr/spawn-tasks.md) | Tâches rapides, tâches longues avec spawn, orchestration asynchrone de sous-agents | | 🐛 [Dépannage](docs/fr/troubleshooting.md) | Problèmes courants et solutions | | 🔧 [Configuration des Outils](docs/fr/tools_configuration.md) | Activation/désactivation par outil, politiques exec | +| 📋 [Compatibilité Matérielle](docs/hardware-compatibility.md) | Cartes testées, exigences minimales, comment ajouter votre carte | ## ClawdChat Rejoignez le Réseau Social d'Agents @@ -225,6 +232,7 @@ Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul mes | `picoclaw skills install` | Installer une compétence | | `picoclaw migrate` | Migrer les données des anciennes versions | | `picoclaw auth login` | S'authentifier auprès des fournisseurs | +| `picoclaw model` | Voir ou changer le modèle par défaut | ### Tâches Planifiées / Rappels diff --git a/README.id.md b/README.id.md new file mode 100644 index 000000000..3f462981c --- /dev/null +++ b/README.id.md @@ -0,0 +1,249 @@ +
+ PicoClaw + +

PicoClaw: Asisten AI Super Ringan berbasis Go

+ +

Perangkat Keras $10 · RAM <10MB · Boot <1 Detik · Ayo, Berangkat!

+

+ Go + Hardware + License +
+ Website + Docs + Wiki +
+ Twitter + + Discord +

+ +[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [English](README.md) | **Bahasa Indonesia** + +
+ +--- + +> **PicoClaw** adalah proyek open-source independen yang diinisiasi oleh [Sipeed](https://sipeed.com). Ditulis sepenuhnya dalam **Go** — bukan fork dari OpenClaw, NanoBot, atau proyek lainnya. + +🦐 PicoClaw adalah asisten AI pribadi yang super ringan, terinspirasi dari [NanoBot](https://github.com/HKUDS/nanobot), ditulis ulang sepenuhnya dalam Go melalui proses "self-bootstrapping" — di mana AI Agent itu sendiri yang memandu seluruh migrasi arsitektur dan optimasi kode. + +⚡️ Berjalan di perangkat keras $10 dengan RAM <10MB: Hemat 99% memori dibanding OpenClaw dan 98% lebih murah dibanding Mac mini! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **🚨 KEAMANAN & SALURAN RESMI** +> +> * **TANPA KRIPTO:** PicoClaw **TIDAK** memiliki token/koin resmi. Semua klaim di `pump.fun` atau platform trading lainnya adalah **PENIPUAN**. +> +> * **DOMAIN RESMI:** Satu-satunya website resmi adalah **[picoclaw.io](https://picoclaw.io)**, dan website perusahaan adalah **[sipeed.com](https://sipeed.com)** +> * **Peringatan:** Banyak domain `.ai/.org/.com/.net/...` yang didaftarkan oleh pihak ketiga. +> * **Peringatan:** PicoClaw masih dalam tahap pengembangan awal dan mungkin memiliki masalah keamanan jaringan yang belum teratasi. Jangan deploy ke lingkungan produksi sebelum rilis v1.0. +> * **Catatan:** PicoClaw baru-baru ini menggabungkan banyak PR, yang mungkin mengakibatkan penggunaan memori lebih besar (10–20MB) pada versi terbaru. Kami berencana untuk memprioritaskan optimasi sumber daya segera setelah fitur saat ini mencapai kondisi stabil. + +## 📢 Berita + +2026-03-17 🚀 **v0.2.3 Dirilis!** UI system tray (Windows & Linux), pelacakan status sub-agent (`spawn_status`), eksperimental gateway hot-reload, gerbang keamanan cron, dan 2 perbaikan keamanan. PicoClaw kini di **25K ⭐**! + +2026-03-09 🎉 **v0.2.1 — Update terbesar!** Dukungan protokol MCP, 4 channel baru (Matrix/IRC/WeCom/Discord Proxy), 3 provider baru (Kimi/Minimax/Avian), pipeline vision, penyimpanan memori JSONL, dan routing model. + +2026-02-28 📦 **v0.2.0** dirilis dengan dukungan Docker Compose dan launcher Web UI. + +2026-02-26 🎉 PicoClaw mencapai **20K bintang** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas diluncurkan. + +
+Berita lama... + +2026-02-16 🎉 PicoClaw mencapai 12K bintang dalam satu minggu! Peran maintainer komunitas dan [roadmap](ROADMAP.md) resmi diposting. + +2026-02-13 🎉 PicoClaw mencapai 5000 bintang dalam 4 hari! Roadmap Proyek dan pengaturan Grup Pengembang sedang berjalan. + +2026-02-09 🎉 **PicoClaw Diluncurkan!** Dibangun dalam 1 hari untuk menghadirkan AI Agent ke perangkat keras $10 dengan RAM <10MB. 🦐 PicoClaw, Ayo Berangkat! + +
+ +## ✨ Fitur + +🪶 **Super Ringan**: Penggunaan memori <10MB — 99% lebih kecil dari fungsionalitas inti OpenClaw.* + +💰 **Biaya Minimal**: Cukup efisien untuk berjalan di perangkat keras $10 — 98% lebih murah dari Mac mini. + +⚡️ **Secepat Kilat**: Waktu startup 400X lebih cepat, boot dalam <1 detik bahkan di prosesor single core 0,6GHz. + +🌍 **Portabilitas Sejati**: Satu binary mandiri untuk RISC-V, ARM, MIPS, dan x86, Satu Klik Langsung Jalan! + +🤖 **AI-Bootstrapped**: Implementasi Go-native secara otonom — 95% kode inti dihasilkan oleh Agent dengan penyempurnaan human-in-the-loop. + +🔌 **Dukungan MCP**: Integrasi [Model Context Protocol](https://modelcontextprotocol.io/) native — hubungkan server MCP mana pun untuk memperluas kapabilitas agent. + +👁️ **Pipeline Vision**: Kirim gambar dan file langsung ke agent — encoding base64 otomatis untuk LLM multimodal. + +🧠 **Routing Cerdas**: Routing model berbasis aturan — kueri sederhana diarahkan ke model ringan, menghemat biaya API. + +_*Versi terbaru mungkin menggunakan 10–20MB karena penggabungan fitur yang cepat. Optimasi sumber daya direncanakan. Perbandingan startup berdasarkan benchmark prosesor single-core 0,8GHz (lihat tabel di bawah)._ + +| | OpenClaw | NanoBot | **PicoClaw** | +| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | +| **Bahasa** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB*** | +| **Startup**
(0,8GHz core) | >500d | >30d | **<1d** | +| **Biaya** | Mac Mini $599 | Kebanyakan Linux SBC
~$50 | **Semua Board Linux**
**Mulai dari $10** | + +PicoClaw + +## 🦾 Demonstrasi + +### 🛠️ Alur Kerja Asisten Standar + + + + + + + + + + + + + + + + + +

🧩 Full-Stack Engineer

🗂️ Pencatatan & Manajemen Perencanaan

🔎 Pencarian Web & Pembelajaran

Develop • Deploy • ScaleJadwal • Otomasi • MemoriPenemuan • 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! + +PicoClaw + +### 🐜 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 + + + +🌟 Lebih Banyak Kasus Deploy Menanti! + +## 📦 Instalasi + +### Instal dengan binary yang sudah dikompilasi + +Unduh binary untuk platform Anda dari halaman [Releases](https://github.com/sipeed/picoclaw/releases). + +### Instal dari source (fitur terbaru, disarankan untuk pengembangan) + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# Build, tidak perlu instal +make build + +# Build untuk berbagai platform +make build-all + +# Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) +make build-pi-zero + +# Build dan Instal +make install +``` + +**Raspberry Pi Zero 2 W:** Gunakan binary yang sesuai dengan OS Anda: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk build keduanya. + +## 📚 Dokumentasi + +Untuk panduan lengkap, lihat dokumen di bawah. README ini hanya berisi panduan cepat. + +| Topik | Deskripsi | +|-------|-----------| +| 🐳 [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent, konfigurasi Panduan Cepat | +| 💬 [Aplikasi Chat](docs/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom, dan lainnya | +| ⚙️ [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sumber skill, sandbox keamanan, heartbeat | +| 🔌 [Provider & Model](docs/providers.md) | 20+ provider LLM, routing model, konfigurasi model_list, arsitektur provider | +| 🔄 [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async | +| 🐛 [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya | +| 🔧 [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan tool, kebijakan exec | + +## ClawdChat Bergabung dengan Jaringan Sosial Agent + +Hubungkan Picoclaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi. + +**Baca `https://clawdchat.ai/skill.md` dan ikuti instruksi untuk bergabung dengan [ClawdChat.ai](https://clawdchat.ai)** + +## 🖥️ Referensi CLI + +| Perintah | Deskripsi | +| ------------------------- | -------------------------------- | +| `picoclaw onboard` | Inisialisasi konfigurasi & workspace | +| `picoclaw agent -m "..."` | Chat dengan agent | +| `picoclaw agent` | Mode chat interaktif | +| `picoclaw gateway` | Mulai gateway | +| `picoclaw status` | Tampilkan status | +| `picoclaw version` | Tampilkan info versi | +| `picoclaw cron list` | Daftar semua tugas terjadwal | +| `picoclaw cron add ...` | Tambah tugas terjadwal | +| `picoclaw cron disable` | Nonaktifkan tugas terjadwal | +| `picoclaw cron remove` | Hapus tugas terjadwal | +| `picoclaw skills list` | Daftar skill yang terinstal | +| `picoclaw skills install` | Instal skill | +| `picoclaw migrate` | Migrasi data dari versi lama | +| `picoclaw auth login` | Autentikasi dengan provider | + +### Tugas Terjadwal / Pengingat + +PicoClaw mendukung pengingat terjadwal dan tugas berulang melalui tool `cron`: + +* **Pengingat satu kali**: "Ingatkan saya dalam 10 menit" → terpicu sekali setelah 10 menit +* **Tugas berulang**: "Ingatkan saya setiap 2 jam" → terpicu setiap 2 jam +* **Ekspresi cron**: "Ingatkan saya jam 9 pagi setiap hari" → menggunakan ekspresi cron + +## 🤝 Kontribusi & Roadmap + +PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca. 🤗 + +Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) lengkap kami. + +Grup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge! + +Grup Pengguna: + +discord: + +PicoClaw diff --git a/README.it.md b/README.it.md index 1f5acadcf..27027d95f 100644 --- a/README.it.md +++ b/README.it.md @@ -18,7 +18,7 @@ Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) | **Italiano** +[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [English](README.md) diff --git a/README.ja.md b/README.ja.md index 5cfd6359a..e5a927505 100644 --- a/README.ja.md +++ b/README.ja.md @@ -18,7 +18,7 @@ Discord

-[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) +[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md) @@ -105,6 +105,8 @@ _*最近のバージョンでは急速な機能マージにより 10〜20MB に PicoClaw +> 📋 **[ハードウェア互換性リスト](docs/hardware-compatibility.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください! + ## 🦾 デモンストレーション ### 🛠️ スタンダードアシスタントワークフロー @@ -139,7 +141,7 @@ _*最近のバージョンでは急速な機能マージにより 10〜20MB に 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 +termux-chroot ./picoclaw onboard # chroot で標準的な Linux ファイルシステムレイアウトを提供 ``` その後「クイックスタート」セクションの手順に従って設定を完了してください! @@ -160,11 +162,15 @@ PicoClaw はほぼすべての Linux デバイスにデプロイできます! ## 📦 インストール -### コンパイル済みバイナリでインストール +### picoclaw.io からダウンロード(推奨) -[リリースページ](https://github.com/sipeed/picoclaw/releases) からお使いのプラットフォーム用のバイナリをダウンロードしてください。 +**[picoclaw.io](https://picoclaw.io)** にアクセス — 公式サイトがプラットフォームを自動検出し、ワンクリックでダウンロードできます。アーキテクチャを手動で選ぶ必要はありません。 -### ソースからインストール(最新機能、開発向け推奨) +### プリコンパイル済みバイナリをダウンロード + +または、[GitHub Releases](https://github.com/sipeed/picoclaw/releases) ページからプラットフォームに合ったバイナリをダウンロードしてください。 + +### ソースからビルド(開発用) ```bash git clone https://github.com/sipeed/picoclaw.git @@ -200,6 +206,7 @@ make install | 🔄 [Spawn & 非同期タスク](docs/ja/spawn-tasks.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション | | 🐛 [トラブルシューティング](docs/ja/troubleshooting.md) | よくある問題と解決策 | | 🔧 [ツール設定](docs/ja/tools_configuration.md) | ツールごとの有効/無効、exec ポリシー | +| 📋 [ハードウェア互換性](docs/hardware-compatibility.md) | テスト済みボード、最小要件、ボードの追加方法 | ## ClawdChat エージェントソーシャルネットワークに参加 @@ -225,6 +232,7 @@ CLI または統合チャットアプリからメッセージを 1 つ送るだ | `picoclaw skills install` | スキルをインストール | | `picoclaw migrate` | 旧バージョンからデータを移行 | | `picoclaw auth login` | プロバイダーへの認証 | +| `picoclaw model` | デフォルトモデルの表示・切替 | ### スケジュールタスク / リマインダー diff --git a/README.md b/README.md index 2420df864..652792d83 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **English** +[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **English** @@ -105,6 +105,757 @@ _*Recent versions may use 10–20MB due to rapid feature merges. Resource optimi PicoClaw +> 📋 **[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 • ScaleSchedule • Automate • MemoryDiscovery • Insights • Trends
+ +### 📱 Run on old Android Phones + +Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start: + +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! + +PicoClaw + +### 🐜 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 + + + +🌟 More Deployment Cases Await! + +## 📦 Install + +### Download from picoclaw.io (Recommended) + +Visit **[picoclaw.io](https://picoclaw.io)** — the official website auto-detects your platform and provides one-click download. No need to manually pick an architecture. + +### Download precompiled binary + +Alternatively, download the binary for your platform from the [GitHub Releases](https://github.com/sipeed/picoclaw/releases) page. + +### Build from source (for development) + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# Build, no need to install +make build + +# 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 +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. + +## 📚 Documentation + +For detailed guides, see the docs below. The README covers quick start only. + +```bash +# 1. Clone this repo +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. First run — auto-generates docker/data/config.json then exits +docker compose -f docker/docker-compose.yml --profile gateway up +# The container prints "First-run setup complete." and stops. + +# 3. Set your API keys +vim docker/data/config.json # Set provider API keys, bot tokens, etc. + +# 4. Start +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +> [!TIP] +> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`. + +```bash +# 5. Check logs +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway + +# 6. Stop +docker compose -f docker/docker-compose.yml --profile gateway down +``` + +### Launcher Mode (Web Console) + +The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat. + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically. + +> [!WARNING] +> The web console does not yet support authentication. Avoid exposing it to the public internet. + +### Agent Mode (One-shot) + +```bash +# Ask a question +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" + +# Interactive mode +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent +``` + +### Update + +```bash +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +### 🚀 Quick Start + +> [!TIP] +> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month). + +**1. Initialize** + +```bash +picoclaw onboard +``` + +**2. Configure** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-5.4", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "your-api-key", + "request_timeout": 300 + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "your-anthropic-key" + } + ], + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 + } + } + } +} +``` + +> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details. +> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s). + +**3. Get API Keys** + +* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **Web Search** (optional): + * [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month) + * [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface + * [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed) + * [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) + * DuckDuckGo - Built-in fallback (no API key required) + +> **Note**: See `config.example.json` for a complete configuration template. + +**4. Chat** + +```bash +picoclaw agent -m "What is 2+2?" +``` + +That's it! You have a working AI assistant in 2 minutes. + +--- + +## 💬 Chat Apps + +Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom + +> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server. + +| Channel | Setup | +| ------------ | ---------------------------------- | +| **Telegram** | Easy (just a token) | +| **Discord** | Easy (bot token + intents) | +| **WhatsApp** | Easy (native: QR scan; or bridge URL) | +| **Matrix** | Medium (homeserver + bot access token) | +| **QQ** | Easy (AppID + AppSecret) | +| **DingTalk** | Medium (app credentials) | +| **LINE** | Medium (credentials + webhook URL) | +| **WeCom AI Bot** | Medium (Token + AES key) | + +
+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. + +
+ +
+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. + +
+ +## ClawdChat 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 | +| `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 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 | + +### 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 + +## 🤝 Contribute & Roadmap + +PRs welcome! The codebase is intentionally small and readable. 🤗 + +See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md). + +Developer group building, join after your first merged PR! + +User Groups: + +discord: + +PicoClaw center"> + PicoClaw + +

PicoClaw: Ultra-Efficient AI Assistant in Go

+ +

$10 Hardware · <10MB RAM · <1s Boot · 皮皮虾,我们走!

+

+ Go + Hardware + License +
+ Website + Docs + Wiki +
+ Twitter + + Discord +

+ +[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **English** + + + +--- + +> **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 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. + +⚡️ 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**. +> +> * **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. + +## 📢 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-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-02-28 📦 **v0.2.0** released with Docker Compose support and Web UI launcher. + +2026-02-26 🎉 PicoClaw hit **20K stars** in just 17 days! Channel auto-orchestration and capability interfaces landed. + +
+Older news... + +2026-02-16 🎉 PicoClaw hit 12K stars in one week! Community maintainer roles and [roadmap](ROADMAP.md) officially posted. + +2026-02-13 🎉 PicoClaw hit 5000 stars in 4 days! Project Roadmap and Developer Group setup underway. + +2026-02-09 🎉 **PicoClaw Launched!** Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go! + +
+ +## ✨ Features + +🪶 **Ultra-Lightweight**: <10MB Memory footprint — 99% smaller than OpenClaw core functionality.* + +💰 **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. + +🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, MIPS, and x86, One-click to Go! + +🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement. + +🔌 **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. + +🧠 **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)._ + +| | 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** | + +PicoClaw + +> 📋 **[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 @@ -191,15 +942,510 @@ make install For detailed guides, see the docs below. The README covers quick start only. -| Topic | Description | -|-------|-------------| -| 🐳 [Docker & Quick Start](docs/docker.md) | Docker Compose setup, Launcher/Agent modes, Quick Start configuration | -| 💬 [Chat Apps](docs/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom, and more | -| ⚙️ [Configuration](docs/configuration.md) | Environment variables, workspace layout, skill sources, security sandbox, heartbeat | -| 🔌 [Providers & Models](docs/providers.md) | 20+ LLM providers, model routing, model_list configuration, provider architecture | -| 🔄 [Spawn & Async Tasks](docs/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration | -| 🐛 [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions | -| 🔧 [Tools Configuration](docs/tools_configuration.md) | Per-tool enable/disable, exec policies | +```bash +# 1. Clone this repo +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. First run — auto-generates docker/data/config.json then exits +docker compose -f docker/docker-compose.yml --profile gateway up +# The container prints "First-run setup complete." and stops. + +# 3. Set your API keys +vim docker/data/config.json # Set provider API keys, bot tokens, etc. + +# 4. Start +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +> [!TIP] +> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`. + +```bash +# 5. Check logs +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway + +# 6. Stop +docker compose -f docker/docker-compose.yml --profile gateway down +``` + +### Launcher Mode (Web Console) + +The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat. + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically. + +> [!WARNING] +> The web console does not yet support authentication. Avoid exposing it to the public internet. + +### Agent Mode (One-shot) + +```bash +# Ask a question +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" + +# Interactive mode +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent +``` + +### Update + +```bash +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +### 🚀 Quick Start + +> [!TIP] +> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month). + +**1. Initialize** + +```bash +picoclaw onboard +``` + +**2. Configure** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-5.4", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "your-api-key", + "request_timeout": 300 + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "your-anthropic-key" + } + ], + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 + } + } + } +} +``` + +> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details. +> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s). + +**3. Get API Keys** + +* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **Web Search** (optional): + * [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month) + * [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface + * [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed) + * [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) + * DuckDuckGo - Built-in fallback (no API key required) + +> **Note**: See `config.example.json` for a complete configuration template. + +**4. Chat** + +```bash +picoclaw agent -m "What is 2+2?" +``` + +That's it! You have a working AI assistant in 2 minutes. + +--- + +## 💬 Chat Apps + +Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom + +> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server. + +| Channel | Setup | +| ------------ | ---------------------------------- | +| **Telegram** | Easy (just a token) | +| **Discord** | Easy (bot token + intents) | +| **WhatsApp** | Easy (native: QR scan; or bridge URL) | +| **Matrix** | Medium (homeserver + bot access token) | +| **QQ** | Easy (AppID + AppSecret) | +| **DingTalk** | Medium (app credentials) | +| **LINE** | Medium (credentials + webhook URL) | +| **WeCom AI Bot** | Medium (Token + AES key) | + +
+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. + +
+ +
+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. + +
## ClawdChat Join the Agent Social Network @@ -246,4 +1492,52 @@ User Groups: discord: +PicoClaw + +## ClawdChat 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 | +| `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 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 model` | View or switch the default model | + +### 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 + +## 🤝 Contribute & Roadmap + +PRs welcome! The codebase is intentionally small and readable. 🤗 + +See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md). + +Developer group building, join after your first merged PR! + +User Groups: + +discord: + PicoClaw diff --git a/README.pt-br.md b/README.pt-br.md index 04f7dae26..c1df570a5 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -18,7 +18,7 @@ Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md) @@ -105,6 +105,8 @@ _*Versões recentes podem usar 10–20MB devido a merges rápidos de funcionalid PicoClaw +> 📋 **[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! + ## 🦾 Demonstração ### 🛠️ Fluxos de Trabalho Padrão do Assistente @@ -139,7 +141,7 @@ Dê uma segunda vida ao seu celular de dez anos atrás! Transforme-o em um assis 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 +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! @@ -160,11 +162,15 @@ O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux! ## 📦 Instalação -### Instalar com binário pré-compilado +### Baixar de picoclaw.io (Recomendado) -Baixe o binário para sua plataforma na página de [Releases](https://github.com/sipeed/picoclaw/releases). +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. -### Instalar a partir do código-fonte (funcionalidades mais recentes, recomendado para desenvolvimento) +### Baixar binário pré-compilado + +Alternativamente, baixe o binário para sua plataforma na página de [GitHub Releases](https://github.com/sipeed/picoclaw/releases). + +### Compilar a partir do código-fonte (para desenvolvimento) ```bash git clone https://github.com/sipeed/picoclaw.git @@ -200,6 +206,7 @@ Para guias detalhados, consulte a documentação abaixo. Este README cobre apena | 🔄 [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 | ## ClawdChat Junte-se à Rede Social de Agentes @@ -225,6 +232,7 @@ Conecte o PicoClaw à Rede Social de Agentes simplesmente enviando uma única me | `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 | ### Tarefas Agendadas / Lembretes diff --git a/README.vi.md b/README.vi.md index 3832890ed..cd65ac526 100644 --- a/README.vi.md +++ b/README.vi.md @@ -18,7 +18,7 @@ Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md) @@ -105,6 +105,8 @@ _*Các phiên bản gần đây có thể sử dụng 10–20MB do merge tính n PicoClaw +> 📋 **[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 ### 🛠️ Quy trình trợ lý tiêu chuẩn @@ -139,7 +141,7 @@ Hãy cho chiếc điện thoại cũ một cuộc sống mới! Biến nó thàn 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 +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! @@ -160,11 +162,15 @@ PicoClaw có thể triển khai trên hầu hết mọi thiết bị Linux! ## 📦 Cài đặt -### Cài đặt bằng binary biên dịch sẵn +### Tải từ picoclaw.io (Khuyến nghị) -Tải file binary cho nền tảng của bạn từ [trang Releases](https://github.com/sipeed/picoclaw/releases). +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. -### Cài đặt từ mã nguồn (có tính năng mới nhất, khuyên dùng cho phát triển) +### Tải 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). + +### Biên dịch từ mã nguồn (cho phát triển) ```bash git clone https://github.com/sipeed/picoclaw.git @@ -200,6 +206,7 @@ make install | 🔄 [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 | ## ClawdChat Tham gia Mạng xã hội Agent @@ -225,6 +232,7 @@ Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một | `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 | ### Tác vụ định kỳ / Nhắc nhở diff --git a/README.zh.md b/README.zh.md index bbb8e8e4d..db34f57da 100644 --- a/README.zh.md +++ b/README.zh.md @@ -18,7 +18,7 @@ Discord

-**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) +**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md) @@ -104,6 +104,8 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入 PicoClaw +> 📋 **[硬件兼容列表](docs/hardware-compatibility.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR! + ## 🦾 演示 ### 🛠️ 标准助手工作流 @@ -138,7 +140,7 @@ PicoClaw 可以将你 10 年前的老旧手机废物利用,变身成为你的 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 +termux-chroot ./picoclaw onboard # chroot 提供标准 Linux 文件系统布局 ``` 然后跟随下面的"快速开始"章节继续配置 PicoClaw 即可使用! @@ -159,11 +161,15 @@ PicoClaw 几乎可以部署在任何 Linux 设备上! ## 📦 安装 -### 使用预编译二进制文件安装 +### 从 picoclaw.io 下载(推荐) -从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的二进制文件。 +访问 **[picoclaw.io](https://picoclaw.io)** — 官网自动检测你的平台,提供一键下载,无需手动选择架构。 -### 从源码安装(获取最新特性,开发推荐) +### 下载预编译二进制文件 + +也可以从 [GitHub Releases](https://github.com/sipeed/picoclaw/releases) 页面手动下载对应平台的二进制文件。 + +### 从源码构建(开发用) ```bash git clone https://github.com/sipeed/picoclaw.git @@ -199,6 +205,7 @@ make install | 🔄 [异步任务与 Spawn](docs/zh/spawn-tasks.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 | | 🐛 [疑难解答](docs/zh/troubleshooting.md) | 常见问题与解决方案 | | 🔧 [工具配置](docs/zh/tools_configuration.md) | 工具启用/禁用、执行策略 | +| 📋 [硬件兼容列表](docs/hardware-compatibility.md) | 已测试板卡、最低要求、如何添加你的板卡 | ## ClawdChat 加入 Agent 社交网络 @@ -224,6 +231,7 @@ make install | `picoclaw skills install` | 安装技能 | | `picoclaw migrate` | 从旧版本迁移数据 | | `picoclaw auth login` | 认证提供商 | +| `picoclaw model` | 查看或切换默认模型 | ### 定时任务 / 提醒 diff --git a/cmd/picoclaw-launcher-tui/config/config.go b/cmd/picoclaw-launcher-tui/config/config.go new file mode 100644 index 000000000..227b9fa3d --- /dev/null +++ b/cmd/picoclaw-launcher-tui/config/config.go @@ -0,0 +1,236 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +// Package config provides types and I/O for ~/.picoclaw/tui.toml. +package config + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" + + "github.com/sipeed/picoclaw/pkg/fileutil" +) + +// DefaultConfigPath returns the default path to the tui.toml config file. +func DefaultConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + return filepath.Join(home, ".picoclaw", "tui.toml") +} + +// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml. +type TUIConfig struct { + Version string `toml:"version"` + Model Model `toml:"model"` + Provider Provider `toml:"provider"` +} + +type Model struct { + Type string `toml:"type"` // "provider" (default) | "manual" +} + +type Provider struct { + Schemes []Scheme `toml:"schemes"` + Users []User `toml:"users"` + Current ProviderCurrent `toml:"current"` +} + +type Scheme struct { + Name string `toml:"name"` // unique key + BaseURL string `toml:"baseURL"` // required + Type string `toml:"type"` // "openai-compatible" (default) | "anthropic" +} + +type User struct { + Name string `toml:"name"` + Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique + Type string `toml:"type"` // "key" (default) | "OAuth" + Key string `toml:"key"` +} + +type ProviderCurrent struct { + Scheme string `toml:"scheme"` // references Scheme.Name + User string `toml:"user"` // references User.Name where User.Scheme == Scheme + Model string `toml:"model"` // from GET /models +} + +// DefaultConfig returns a minimal valid TUIConfig. +func DefaultConfig() *TUIConfig { + return &TUIConfig{ + Version: "1.0", + Model: Model{Type: "provider"}, + Provider: Provider{ + Schemes: []Scheme{}, + Users: []User{}, + Current: ProviderCurrent{}, + }, + } +} + +// Load reads the TUI config from path. Returns a default config if the file does not exist. +func Load(path string) (*TUIConfig, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return DefaultConfig(), nil + } + if err != nil { + return nil, fmt.Errorf("failed to read config file %q: %w", path, err) + } + + cfg := DefaultConfig() + if _, err := toml.Decode(string(data), cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file %q: %w", path, err) + } + + applyDefaults(cfg) + return cfg, nil +} + +// Save writes cfg to path atomically (safe for flash / SD storage). +func Save(path string, cfg *TUIConfig) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + var buf bytes.Buffer + enc := toml.NewEncoder(&buf) + if err := enc.Encode(cfg); err != nil { + return fmt.Errorf("failed to encode config: %w", err) + } + if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil { + return fmt.Errorf("failed to write config file %q: %w", path, err) + } + return nil +} + +func applyDefaults(cfg *TUIConfig) { + if cfg.Version == "" { + cfg.Version = "1.0" + } + if cfg.Model.Type == "" { + cfg.Model.Type = "provider" + } + for i := range cfg.Provider.Schemes { + if cfg.Provider.Schemes[i].Type == "" { + cfg.Provider.Schemes[i].Type = "openai-compatible" + } + } + for i := range cfg.Provider.Users { + if cfg.Provider.Users[i].Type == "" { + cfg.Provider.Users[i].Type = "key" + } + } +} + +// SchemeByName returns the first Scheme whose Name matches, or nil. +func (p *Provider) SchemeByName(name string) *Scheme { + for i := range p.Schemes { + if p.Schemes[i].Name == name { + return &p.Schemes[i] + } + } + return nil +} + +// UsersForScheme returns all users whose Scheme field matches schemeName. +func (p *Provider) UsersForScheme(schemeName string) []User { + var out []User + for _, u := range p.Users { + if u.Scheme == schemeName { + out = append(out, u) + } + } + return out +} + +// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json +// Adds/replaces a "tui-prefer" model entry and sets it as the default model. +// Preserves all other existing fields in the config file unchanged. +func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + mainConfigPath := filepath.Join(home, ".picoclaw", "config.json") + + var cfg map[string]any + if data, readErr := os.ReadFile(mainConfigPath); readErr == nil { + if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil { + cfg = make(map[string]any) + } + } else { + cfg = make(map[string]any) + } + + if _, ok := cfg["agents"]; !ok { + cfg["agents"] = make(map[string]any) + } + agents, ok := cfg["agents"].(map[string]any) + if ok { + if _, ok := agents["defaults"]; !ok { + agents["defaults"] = make(map[string]any) + } + defaults, ok := agents["defaults"].(map[string]any) + if ok { + defaults["model"] = "tui-prefer" + } + } + + tuiModel := map[string]any{ + "model_name": "tui-prefer", + "model": modelID, + "api_key": user.Key, + "api_base": scheme.BaseURL, + } + + modelList := []any{} + if ml, ok := cfg["model_list"].([]any); ok { + modelList = ml + } + + found := false + for i, m := range modelList { + if entry, ok := m.(map[string]any); ok { + if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" { + modelList[i] = tuiModel + found = true + break + } + } + } + if !found { + modelList = append(modelList, tuiModel) + } + cfg["model_list"] = modelList + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil { + return err + } + + return os.WriteFile(mainConfigPath, data, 0o600) +} + +func (cfg *TUIConfig) CurrentModelLabel() string { + cur := cfg.Provider.Current + if cur.Model == "" { + return "(not configured)" + } + label := cur.Scheme + if label != "" { + label += " / " + } + return label + cur.Model +} diff --git a/cmd/picoclaw-launcher-tui/internal/config/store.go b/cmd/picoclaw-launcher-tui/internal/config/store.go deleted file mode 100644 index 0236de19f..000000000 --- a/cmd/picoclaw-launcher-tui/internal/config/store.go +++ /dev/null @@ -1,49 +0,0 @@ -package configstore - -import ( - "errors" - "os" - "path/filepath" - - picoclawconfig "github.com/sipeed/picoclaw/pkg/config" -) - -const ( - configDirName = ".picoclaw" - configFileName = "config.json" -) - -func ConfigPath() (string, error) { - dir, err := ConfigDir() - if err != nil { - return "", err - } - return filepath.Join(dir, configFileName), nil -} - -func ConfigDir() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, configDirName), nil -} - -func Load() (*picoclawconfig.Config, error) { - path, err := ConfigPath() - if err != nil { - return nil, err - } - return picoclawconfig.LoadConfig(path) -} - -func Save(cfg *picoclawconfig.Config) error { - if cfg == nil { - return errors.New("config is nil") - } - path, err := ConfigPath() - if err != nil { - return err - } - return picoclawconfig.SaveConfig(path, cfg) -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/app.go b/cmd/picoclaw-launcher-tui/internal/ui/app.go deleted file mode 100644 index dced3ba56..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/app.go +++ /dev/null @@ -1,522 +0,0 @@ -package ui - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config" - picoclawconfig "github.com/sipeed/picoclaw/pkg/config" -) - -type appState struct { - app *tview.Application - pages *tview.Pages - stack []string - config *picoclawconfig.Config - configPath string - gatewayCmd *exec.Cmd - menus map[string]*Menu - original []byte - hasOriginal bool - backupPath string - dirty bool - logPath string -} - -func Run() error { - applyStyles() - cfg, err := configstore.Load() - if err != nil { - return err - } - path, err := configstore.ConfigPath() - if err != nil { - return err - } - - if cfg == nil { - cfg = picoclawconfig.DefaultConfig() - } - - originalData, hasOriginal := loadOriginalConfig(path) - backupPath := path + ".bak" - if hasOriginal { - _ = writeBackupConfig(backupPath, originalData) - } - - logPath := filepath.Join(filepath.Dir(path), "gateway.log") - state := &appState{ - app: tview.NewApplication(), - pages: tview.NewPages(), - config: cfg, - configPath: path, - menus: map[string]*Menu{}, - original: originalData, - hasOriginal: hasOriginal, - backupPath: backupPath, - logPath: logPath, - } - - state.push("main", state.mainMenu()) - - root := tview.NewFlex().SetDirection(tview.FlexRow) - root.AddItem(bannerView(), 6, 0, false) - root.AddItem(state.pages, 0, 1, true) - root.AddItem(footerView(), 1, 0, false) - - if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil { - return err - } - return nil -} - -func (s *appState) push(name string, primitive tview.Primitive) { - s.pages.AddPage(name, primitive, true, true) - s.stack = append(s.stack, name) - s.pages.SwitchToPage(name) - if menu, ok := primitive.(*Menu); ok { - s.menus[name] = menu - } -} - -func (s *appState) pop() { - if len(s.stack) == 0 { - return - } - last := s.stack[len(s.stack)-1] - s.pages.RemovePage(last) - s.stack = s.stack[:len(s.stack)-1] - if len(s.stack) == 0 { - s.app.Stop() - return - } - current := s.stack[len(s.stack)-1] - s.pages.SwitchToPage(current) - if menu, ok := s.menus[current]; ok { - s.refreshMenu(current, menu) - } -} - -func (s *appState) mainMenu() tview.Primitive { - menu := NewMenu("Menu", nil) - refreshMainMenu(menu, s) - menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyEsc: - s.requestExit() - return nil - } - - return event - }) - - return menu -} - -func (s *appState) refreshMenu(name string, menu *Menu) { - switch name { - case "main": - refreshMainMenu(menu, s) - case "model": - refreshModelMenuFromState(menu, s) - case "channel": - refreshChannelMenuFromState(menu, s) - } -} - -func (s *appState) countChannels() (enabled int, total int) { - c := s.config.Channels - entries := []bool{ - c.Telegram.Enabled, - c.Discord.Enabled, - c.QQ.Enabled, - c.MaixCam.Enabled, - c.WhatsApp.Enabled, - c.Feishu.Enabled, - c.DingTalk.Enabled, - c.Slack.Enabled, - c.Matrix.Enabled, - c.LINE.Enabled, - c.OneBot.Enabled, - c.WeCom.Enabled, - c.WeComApp.Enabled, - } - total = len(entries) - for _, v := range entries { - if v { - enabled++ - } - } - return enabled, total -} - -func refreshMainMenuIfPresent(s *appState) { - if menu, ok := s.menus["main"]; ok { - refreshMainMenu(menu, s) - } -} - -func refreshMainMenu(menu *Menu, s *appState) { - selectedModel := s.selectedModelName() - modelReady := selectedModel != "" - channelReady := s.hasEnabledChannel() - enabledCount, totalChannels := s.countChannels() - gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning() - - gatewayLabel := "Start Gateway" - gatewayDescription := "Launch gateway for channels" - if gatewayRunning { - gatewayLabel = "Stop Gateway" - gatewayDescription = "Gateway running" - } - - items := []MenuItem{ - { - Label: rootModelLabel(selectedModel), - Description: rootModelDescription(), - Action: func() { - s.push("model", s.modelMenu()) - }, - MainColor: func() *tcell.Color { - if modelReady { - return nil - } - color := tcell.ColorGray - return &color - }(), - }, - { - Label: rootChannelLabel(channelReady), - Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels), - Action: func() { - s.push("channel", s.channelMenu()) - }, - MainColor: func() *tcell.Color { - if channelReady { - return nil - } - color := tcell.ColorGray - return &color - }(), - }, - { - Label: "Start Talk", - Description: "Open picoclaw agent in terminal", - Action: func() { - s.requestStartTalk() - }, - Disabled: !modelReady, - }, - { - Label: gatewayLabel, - Description: gatewayDescription, - Action: func() { - if gatewayRunning { - s.stopGateway() - } else { - s.requestStartGateway() - } - refreshMainMenu(menu, s) - }, - Disabled: !gatewayRunning && (!modelReady || !channelReady), - }, - { - Label: "View Gateway Log", - Description: "Open gateway.log", - Action: func() { - s.viewGatewayLog() - }, - }, - { - Label: "Exit", - Description: "Exit the TUI", - Action: func() { - s.requestExit() - }, - }, - } - menu.applyItems(items) -} - -func (s *appState) applyChangesValidated() bool { - if err := s.config.ValidateModelList(); err != nil { - s.showMessage("Validation failed", err.Error()) - return false - } - if err := s.validateAgentModel(); err != nil { - s.showMessage("Validation failed", err.Error()) - return false - } - if err := configstore.Save(s.config); err != nil { - s.showMessage("Save failed", err.Error()) - return false - } - if data, err := os.ReadFile(s.configPath); err == nil { - s.original = data - s.hasOriginal = true - _ = writeBackupConfig(s.backupPath, data) - } - return true -} - -func (s *appState) requestExit() { - if s.dirty { - s.confirmApplyOrDiscard(func() { - s.app.Stop() - }, func() { - s.discardChanges() - s.app.Stop() - }) - return - } - s.app.Stop() -} - -func (s *appState) requestStartTalk() { - if s.dirty { - s.confirmApplyOrDiscard(func() { - s.startTalk() - }, func() { - s.startTalk() - }) - return - } - s.startTalk() -} - -func (s *appState) requestStartGateway() { - if s.dirty { - s.confirmApplyOrDiscard(func() { - s.startGateway() - }, func() { - s.startGateway() - }) - return - } - s.startGateway() -} - -func (s *appState) viewGatewayLog() { - data, err := os.ReadFile(s.logPath) - if err != nil { - s.showMessage("Log not found", "gateway.log not found") - return - } - text := tview.NewTextView() - text.SetBorder(true).SetTitle("Gateway Log") - text.SetText(string(data)) - text.SetDoneFunc(func(key tcell.Key) { - s.pages.RemovePage("log") - }) - text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - s.pages.RemovePage("log") - return nil - } - return event - }) - s.pages.AddPage("log", text, true, true) -} - -func (s *appState) selectedModelName() string { - modelName := strings.TrimSpace(s.config.Agents.Defaults.GetModelName()) - if modelName == "" { - return "" - } - if !s.isActiveModelValid() { - return "" - } - return modelName -} - -func rootModelLabel(selected string) string { - if selected == "" { - return "Model (None)" - } - return "Model (" + selected + ")" -} - -func rootModelDescription() string { - return "Using SPACE to choose your model" -} - -func rootChannelLabel(valid bool) string { - if !valid { - return "Channel (no channel enabled)" - } - return "Channel" -} - -func (s *appState) startTalk() { - if !s.isActiveModelValid() { - s.showMessage("Model required", "Select a valid model before starting talk") - return - } - if !s.applyChangesValidated() { - return - } - s.app.Suspend(func() { - cmd := exec.Command("picoclaw", "agent") - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - _ = cmd.Run() - }) -} - -func (s *appState) startGateway() { - if !s.isActiveModelValid() { - s.showMessage("Model required", "Select a valid model before starting gateway") - return - } - if !s.hasEnabledChannel() { - s.showMessage("Channel required", "Enable at least one channel before starting gateway") - return - } - if !s.applyChangesValidated() { - return - } - _ = stopGatewayProcess() - cmd := exec.Command("picoclaw", "gateway") - logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) - if err != nil { - s.showMessage("Gateway failed", err.Error()) - return - } - cmd.Stdout = logFile - cmd.Stderr = logFile - if err := cmd.Start(); err != nil { - s.showMessage("Gateway failed", err.Error()) - _ = logFile.Close() - return - } - _ = logFile.Close() - s.gatewayCmd = cmd -} - -func (s *appState) stopGateway() { - _ = stopGatewayProcess() - if s.gatewayCmd != nil && s.gatewayCmd.Process != nil { - _ = s.gatewayCmd.Process.Kill() - } - s.gatewayCmd = nil -} - -func (s *appState) isGatewayRunning() bool { - return isGatewayProcessRunning() -} - -func (s *appState) validateAgentModel() error { - modelName := strings.TrimSpace(s.config.Agents.Defaults.GetModelName()) - if modelName == "" { - return nil - } - _, err := s.config.GetModelConfig(modelName) - return err -} - -func (s *appState) isActiveModelValid() bool { - modelName := strings.TrimSpace(s.config.Agents.Defaults.GetModelName()) - if modelName == "" { - return false - } - cfg, err := s.config.GetModelConfig(modelName) - if err != nil { - return false - } - hasKey := strings.TrimSpace(cfg.APIKey()) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth" - hasModel := strings.TrimSpace(cfg.Model) != "" - return hasKey && hasModel -} - -func (s *appState) hasEnabledChannel() bool { - c := s.config.Channels - return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled || - c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled || - c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled -} - -func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) { - if s.pages.HasPage("apply") { - return - } - modal := tview.NewModal(). - SetText("Apply changes or discard before continuing?"). - AddButtons([]string{"Cancel", "Discard", "Apply"}). - SetDoneFunc(func(buttonIndex int, buttonLabel string) { - s.pages.RemovePage("apply") - switch buttonLabel { - case "Discard": - s.discardChanges() - if onDiscard != nil { - onDiscard() - } - case "Apply": - if s.applyChangesValidated() { - s.dirty = false - if onApply != nil { - onApply() - } - } - } - }) - modal.SetBorder(true) - s.pages.AddPage("apply", modal, true, true) -} - -func (s *appState) discardChanges() { - if s.hasOriginal { - _ = writeOriginalConfig(s.configPath, s.original) - } else { - _ = os.Remove(s.configPath) - } - _ = os.Remove(s.backupPath) - if cfg, err := configstore.Load(); err == nil && cfg != nil { - s.config = cfg - } - s.dirty = false - refreshMainMenuIfPresent(s) -} - -func (s *appState) showMessage(title, message string) { - if s.pages.HasPage("message") { - return - } - modal := tview.NewModal(). - SetText(strings.TrimSpace(message)). - AddButtons([]string{"OK"}). - SetDoneFunc(func(_ int, _ string) { - s.pages.RemovePage("message") - }) - modal.SetTitle(title).SetBorder(true) - modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor) - modal.SetTextColor(tview.Styles.PrimaryTextColor) - modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255)) - modal.SetButtonTextColor(tview.Styles.PrimaryTextColor) - s.pages.AddPage("message", modal, true, true) -} - -func loadOriginalConfig(path string) ([]byte, bool) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, false - } - return nil, false - } - return data, true -} - -func writeOriginalConfig(path string, data []byte) error { - return os.WriteFile(path, data, 0o600) -} - -func writeBackupConfig(path string, data []byte) error { - return os.WriteFile(path, data, 0o600) -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/channel.go b/cmd/picoclaw-launcher-tui/internal/ui/channel.go deleted file mode 100644 index 7d64407af..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/channel.go +++ /dev/null @@ -1,433 +0,0 @@ -package ui - -import ( - "fmt" - "strings" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - picoclawconfig "github.com/sipeed/picoclaw/pkg/config" -) - -func (s *appState) buildChannelMenuItems() []MenuItem { - return []MenuItem{ - channelItem( - "Telegram", - "Telegram bot settings", - s.config.Channels.Telegram.Enabled, - func() { s.push("channel-telegram", s.telegramForm()) }, - ), - channelItem( - "Discord", - "Discord bot settings", - s.config.Channels.Discord.Enabled, - func() { s.push("channel-discord", s.discordForm()) }, - ), - channelItem( - "QQ", - "QQ bot settings", - s.config.Channels.QQ.Enabled, - func() { s.push("channel-qq", s.qqForm()) }, - ), - channelItem( - "MaixCam", - "MaixCam gateway", - s.config.Channels.MaixCam.Enabled, - func() { s.push("channel-maixcam", s.maixcamForm()) }, - ), - channelItem( - "WhatsApp", - "WhatsApp bridge", - s.config.Channels.WhatsApp.Enabled, - func() { s.push("channel-whatsapp", s.whatsappForm()) }, - ), - channelItem( - "Feishu", - "Feishu bot settings", - s.config.Channels.Feishu.Enabled, - func() { s.push("channel-feishu", s.feishuForm()) }, - ), - channelItem( - "DingTalk", - "DingTalk bot settings", - s.config.Channels.DingTalk.Enabled, - func() { s.push("channel-dingtalk", s.dingtalkForm()) }, - ), - channelItem( - "Slack", - "Slack bot settings", - s.config.Channels.Slack.Enabled, - func() { s.push("channel-slack", s.slackForm()) }, - ), - channelItem( - "Matrix", - "Matrix bot settings", - s.config.Channels.Matrix.Enabled, - func() { s.push("channel-matrix", s.matrixForm()) }, - ), - channelItem( - "LINE", - "LINE bot settings", - s.config.Channels.LINE.Enabled, - func() { s.push("channel-line", s.lineForm()) }, - ), - channelItem( - "OneBot", - "OneBot settings", - s.config.Channels.OneBot.Enabled, - func() { s.push("channel-onebot", s.onebotForm()) }, - ), - channelItem( - "WeCom", - "WeCom bot settings", - s.config.Channels.WeCom.Enabled, - func() { s.push("channel-wecom", s.wecomForm()) }, - ), - channelItem( - "WeCom App", - "WeCom App settings", - s.config.Channels.WeComApp.Enabled, - func() { s.push("channel-wecomapp", s.wecomAppForm()) }, - ), - } -} - -func (s *appState) channelMenu() tview.Primitive { - menu := NewMenu("Channels", s.buildChannelMenuItems()) - menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - s.pop() - return nil - } - return event - }) - return menu -} - -func refreshChannelMenuFromState(menu *Menu, s *appState) { - menu.applyItems(s.buildChannelMenuItems()) -} - -func (s *appState) telegramForm() tview.Primitive { - cfg := &s.config.Channels.Telegram - form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Token", cfg.Token(), 128, nil, func(text string) { - cfg.SetToken(strings.TrimSpace(text)) - }) - form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) { - cfg.Proxy = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) discordForm() tview.Primitive { - cfg := &s.config.Channels.Discord - form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Token", cfg.Token(), 128, nil, func(text string) { - cfg.SetToken(strings.TrimSpace(text)) - }) - form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) { - cfg.MentionOnly = checked - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) qqForm() tview.Primitive { - cfg := &s.config.Channels.QQ - form := baseChannelForm("QQ", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) { - cfg.AppID = strings.TrimSpace(text) - }) - form.AddInputField("App Secret", cfg.AppSecret(), 128, nil, func(text string) { - cfg.SetAppSecret(strings.TrimSpace(text)) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) maixcamForm() tview.Primitive { - cfg := &s.config.Channels.MaixCam - form := baseChannelForm("MaixCam", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Host", cfg.Host, 64, nil, func(text string) { - cfg.Host = strings.TrimSpace(text) - }) - addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) whatsappForm() tview.Primitive { - cfg := &s.config.Channels.WhatsApp - form := baseChannelForm("WhatsApp", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) { - cfg.BridgeURL = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) feishuForm() tview.Primitive { - cfg := &s.config.Channels.Feishu - form := baseChannelForm("Feishu", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) { - cfg.AppID = strings.TrimSpace(text) - }) - form.AddInputField("App Secret", cfg.AppSecret(), 128, nil, func(text string) { - cfg.SetAppSecret(strings.TrimSpace(text)) - }) - form.AddInputField("Encrypt Key", cfg.EncryptKey(), 128, nil, func(text string) { - cfg.SetEncryptKey(strings.TrimSpace(text)) - }) - form.AddInputField("Verification Token", cfg.VerificationToken(), 128, nil, func(text string) { - cfg.SetVerificationToken(strings.TrimSpace(text)) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) dingtalkForm() tview.Primitive { - cfg := &s.config.Channels.DingTalk - form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) { - cfg.ClientID = strings.TrimSpace(text) - }) - form.AddInputField("Client Secret", cfg.ClientSecret(), 128, nil, func(text string) { - cfg.SetClientSecret(strings.TrimSpace(text)) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) slackForm() tview.Primitive { - cfg := &s.config.Channels.Slack - form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Bot Token", cfg.BotToken(), 128, nil, func(text string) { - cfg.SetBotToken(strings.TrimSpace(text)) - }) - form.AddInputField("App Token", cfg.AppToken(), 128, nil, func(text string) { - cfg.SetAppToken(strings.TrimSpace(text)) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) lineForm() tview.Primitive { - cfg := &s.config.Channels.LINE - form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Channel Secret", cfg.ChannelSecret(), 128, nil, func(text string) { - cfg.SetChannelSecret(strings.TrimSpace(text)) - }) - form.AddInputField("Channel Access Token", cfg.ChannelAccessToken(), 128, nil, func(text string) { - cfg.SetChannelAccessToken(strings.TrimSpace(text)) - }) - form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) { - cfg.WebhookHost = strings.TrimSpace(text) - }) - addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value }) - form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { - cfg.WebhookPath = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) matrixForm() tview.Primitive { - cfg := &s.config.Channels.Matrix - form := baseChannelForm("Matrix", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Homeserver", cfg.Homeserver, 128, nil, func(text string) { - cfg.Homeserver = strings.TrimSpace(text) - }) - form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) { - cfg.UserID = strings.TrimSpace(text) - }) - form.AddInputField("Access Token", cfg.AccessToken(), 128, nil, func(text string) { - cfg.SetAccessToken(strings.TrimSpace(text)) - }) - form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) { - cfg.DeviceID = strings.TrimSpace(text) - }) - form.AddCheckbox("Join On Invite", cfg.JoinOnInvite, func(checked bool) { - cfg.JoinOnInvite = checked - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) onebotForm() tview.Primitive { - cfg := &s.config.Channels.OneBot - form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) { - cfg.WSUrl = strings.TrimSpace(text) - }) - form.AddInputField("Access Token", cfg.AccessToken(), 128, nil, func(text string) { - cfg.SetAccessToken(strings.TrimSpace(text)) - }) - addIntField( - form, - "Reconnect Interval", - cfg.ReconnectInterval, - func(value int) { cfg.ReconnectInterval = value }, - ) - form.AddInputField( - "Group Trigger Prefix", - strings.Join(cfg.GroupTriggerPrefix, ","), - 128, - nil, - func(text string) { - cfg.GroupTriggerPrefix = splitCSV(text) - }, - ) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) wecomForm() tview.Primitive { - cfg := &s.config.Channels.WeCom - form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Token", cfg.Token(), 128, nil, func(text string) { - cfg.SetToken(strings.TrimSpace(text)) - }) - form.AddInputField("Encoding AES Key", cfg.EncodingAESKey(), 128, nil, func(text string) { - cfg.SetEncodingAESKey(strings.TrimSpace(text)) - }) - form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) { - cfg.WebhookURL = strings.TrimSpace(text) - }) - form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) { - cfg.WebhookHost = strings.TrimSpace(text) - }) - addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value }) - form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { - cfg.WebhookPath = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - addIntField( - form, - "Reply Timeout", - cfg.ReplyTimeout, - func(value int) { cfg.ReplyTimeout = value }, - ) - return wrapWithBack(form, s) -} - -func (s *appState) wecomAppForm() tview.Primitive { - cfg := &s.config.Channels.WeComApp - form := baseChannelForm("WeCom App", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) { - cfg.CorpID = strings.TrimSpace(text) - }) - form.AddInputField("Corp Secret", cfg.CorpSecret(), 128, nil, func(text string) { - cfg.SetCorpSecret(strings.TrimSpace(text)) - }) - addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value }) - form.AddInputField("Token", cfg.Token(), 128, nil, func(text string) { - cfg.SetToken(strings.TrimSpace(text)) - }) - form.AddInputField("Encoding AES Key", cfg.EncodingAESKey(), 128, nil, func(text string) { - cfg.SetEncodingAESKey(strings.TrimSpace(text)) - }) - form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) { - cfg.WebhookHost = strings.TrimSpace(text) - }) - addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value }) - form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { - cfg.WebhookPath = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - addIntField( - form, - "Reply Timeout", - cfg.ReplyTimeout, - func(value int) { cfg.ReplyTimeout = value }, - ) - return wrapWithBack(form, s) -} - -func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) { - return func(v bool) { - *enabledPtr = v - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["channel"]; ok { - refreshChannelMenuFromState(menu, s) - } - } -} - -func addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) { - form.AddInputField("Allow From", strings.Join(*allowFrom, ","), 128, nil, func(text string) { - *allowFrom = splitCSV(text) - }) -} - -func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form { - form := tview.NewForm() - form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title)) - form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123)) - form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22)) - form.AddCheckbox("Enabled", enabled, func(checked bool) { - onEnabled(checked) - }) - return form -} - -func wrapWithBack(form *tview.Form, s *appState) tview.Primitive { - form.AddButton("Back", func() { - s.pop() - }) - form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - s.pop() - return nil - } - return event - }) - return form -} - -func splitCSV(input string) picoclawconfig.FlexibleStringSlice { - parts := strings.Split(strings.TrimSpace(input), ",") - cleaned := make([]string, 0, len(parts)) - for _, part := range parts { - value := strings.TrimSpace(part) - if value == "" { - continue - } - cleaned = append(cleaned, value) - } - return cleaned -} - -func addIntField(form *tview.Form, label string, value int, onChange func(int)) { - form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) { - var parsed int - if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil { - onChange(parsed) - } - }) -} - -func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) { - form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) { - var parsed int64 - if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil { - onChange(parsed) - } - }) -} - -func channelItem(label, description string, enabled bool, action MenuAction) MenuItem { - item := MenuItem{ - Label: label, - Description: description, - Action: action, - } - if !enabled { - color := tcell.ColorGray - item.MainColor = &color - } - return item -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go b/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go deleted file mode 100644 index bc874f7f2..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build !windows -// +build !windows - -package ui - -import "os/exec" - -func isGatewayProcessRunning() bool { - cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1") - return cmd.Run() == nil -} - -func stopGatewayProcess() error { - cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1") - return cmd.Run() -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go b/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go deleted file mode 100644 index 7067a5c13..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build windows -// +build windows - -package ui - -import "os/exec" - -func isGatewayProcessRunning() bool { - cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe") - return cmd.Run() == nil -} - -func stopGatewayProcess() error { - cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe") - return cmd.Run() -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/menu.go b/cmd/picoclaw-launcher-tui/internal/ui/menu.go deleted file mode 100644 index 9f2132c5a..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/menu.go +++ /dev/null @@ -1,72 +0,0 @@ -package ui - -import ( - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -type MenuAction func() - -type MenuItem struct { - Label string - Description string - Action MenuAction - Disabled bool - MainColor *tcell.Color - DescColor *tcell.Color -} - -type Menu struct { - *tview.Table - items []MenuItem -} - -func NewMenu(title string, items []MenuItem) *Menu { - table := tview.NewTable().SetSelectable(true, false) - table.SetBorder(true).SetTitle(title) - table.SetBorders(false) - menu := &Menu{Table: table, items: items} - menu.applyItems(items) - menu.SetSelectedFunc(func(row, _ int) { - if row < 0 || row >= len(menu.items) { - return - } - item := menu.items[row] - if item.Disabled || item.Action == nil { - return - } - item.Action() - }) - menu.SetSelectedStyle( - tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor). - Background(tcell.NewRGBColor(189, 147, 249)), - ) - return menu -} - -func (m *Menu) applyItems(items []MenuItem) { - m.items = items - m.Clear() - for row, item := range items { - label := item.Label - if item.Disabled && label != "" { - label = label + " (disabled)" - } - left := tview.NewTableCell(label) - right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight) - if item.MainColor != nil { - left.SetTextColor(*item.MainColor) - } - if item.DescColor != nil { - right.SetTextColor(*item.DescColor) - } else { - right.SetTextColor(tview.Styles.TertiaryTextColor) - } - if item.Disabled { - left.SetTextColor(tcell.ColorGray) - right.SetTextColor(tcell.ColorGray) - } - m.SetCell(row, 0, left) - m.SetCell(row, 1, right) - } -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/model.go b/cmd/picoclaw-launcher-tui/internal/ui/model.go deleted file mode 100644 index 4488619ae..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/model.go +++ /dev/null @@ -1,399 +0,0 @@ -package ui - -import ( - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - picoclawconfig "github.com/sipeed/picoclaw/pkg/config" -) - -func (s *appState) modelMenu() tview.Primitive { - items := make([]MenuItem, 0, 1+len(s.config.ModelList)) - currentModel := strings.TrimSpace(s.config.Agents.Defaults.ModelName) - for i := range s.config.ModelList { - index := i - model := s.config.ModelList[i] - isValid := isModelValid(model) - desc := model.APIBase - if desc == "" { - desc = model.AuthMethod - } - if desc == "" { - desc = "api_key required" - } - label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) - if model.ModelName == currentModel && currentModel != "" { - label = "* " + label - } - isSelected := model.ModelName == currentModel && currentModel != "" - items = append(items, MenuItem{ - Label: label, - Description: desc, - MainColor: modelStatusColor(isValid, isSelected), - Action: func() { - s.push(fmt.Sprintf("model-%d", index), s.modelForm(index)) - }, - }) - } - // Add model entry appended at the end so the models map to rows 1..N - items = append(items, - MenuItem{ - Label: "**Add model**", - Description: "Append a new model entry", - Action: func() { - newName := s.nextAvailableModelName("new-model") - s.addModel( - &picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"}, - ) - s.push( - fmt.Sprintf("model-%d", len(s.config.ModelList)-1), - s.modelForm(len(s.config.ModelList)-1), - ) - }, - }, - ) - - menu := NewMenu("Models", items) - menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - s.pop() - return nil - } - - if event.Rune() == ' ' { - row, _ := menu.GetSelection() - if row >= 0 && row < len(s.config.ModelList) { - model := s.config.ModelList[row] - if !isModelValid(model) { - s.showMessage( - "Invalid model", - "Select a model with api_key or oauth auth_method", - ) - return nil - } - s.config.Agents.Defaults.ModelName = model.ModelName - s.dirty = true - refreshModelMenu(menu, s.config.Agents.Defaults.GetModelName(), s.config.ModelList) - refreshMainMenuIfPresent(s) - } - return nil - } - return event - }) - return menu -} - -func (s *appState) modelForm(index int) tview.Primitive { - model := s.config.ModelList[index] - form := tview.NewForm() - form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName)) - - addInput(form, "Model Name", model.ModelName, func(value string) { - if value == "" { - s.showMessage("Invalid model name", "Model Name cannot be empty") - return - } - if s.modelNameExists(value, index) { - s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value)) - return - } - oldName := model.ModelName - model.ModelName = value - if s.config.Agents.Defaults.ModelName == oldName { - s.config.Agents.Defaults.ModelName = value - } - s.dirty = true - form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName)) - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["model"]; ok { - refreshModelMenuFromState(menu, s) - } - }) - addInput(form, "Model", model.Model, func(value string) { - model.Model = value - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["model"]; ok { - refreshModelMenuFromState(menu, s) - } - }) - addInput(form, "API Base", model.APIBase, func(value string) { - model.APIBase = value - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["model"]; ok { - refreshModelMenuFromState(menu, s) - } - }) - addInput(form, "API Key", model.APIKey(), func(value string) { - model.SetAPIKey(value) - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["model"]; ok { - refreshModelMenuFromState(menu, s) - } - }) - addInput(form, "Proxy", model.Proxy, func(value string) { - model.Proxy = value - }) - addInput(form, "Auth Method", model.AuthMethod, func(value string) { - model.AuthMethod = value - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["model"]; ok { - refreshModelMenuFromState(menu, s) - } - }) - addInput(form, "Connect Mode", model.ConnectMode, func(value string) { - model.ConnectMode = value - }) - addInput(form, "Workspace", model.Workspace, func(value string) { - model.Workspace = value - }) - addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) { - model.MaxTokensField = value - }) - addIntInput(form, "RPM", model.RPM, func(value int) { - model.RPM = value - }) - addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) { - model.RequestTimeout = value - }) - - form.AddButton("Delete", func() { - pageName := "confirm-delete-model" - if s.pages.HasPage(pageName) { - return - } - modal := tview.NewModal(). - SetText("Are you sure you want to delete this model?"). - AddButtons([]string{"Cancel", "Delete"}). - SetDoneFunc(func(buttonIndex int, buttonLabel string) { - s.pages.RemovePage(pageName) - if buttonLabel == "Delete" { - s.deleteModel(index) - } - }) - modal.SetTitle("Confirm Delete").SetBorder(true) - s.pages.AddPage(pageName, modal, true, true) - }) - form.AddButton("Test", func() { - s.testModel(model) - }) - form.AddButton("Back", func() { - s.pop() - }) - - form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - s.pop() - return nil - } - return event - }) - return form -} - -func addInput(form *tview.Form, label, value string, onChange func(string)) { - form.AddInputField(label, value, 128, nil, func(text string) { - onChange(strings.TrimSpace(text)) - }) -} - -func addIntInput(form *tview.Form, label string, value int, onChange func(int)) { - form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) { - var parsed int - if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil { - onChange(parsed) - } - }) -} - -func (s *appState) addModel(model *picoclawconfig.ModelConfig) { - s.config.ModelList = append(s.config.ModelList, model) -} - -func (s *appState) deleteModel(index int) { - if index < 0 || index >= len(s.config.ModelList) { - return - } - s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...) - s.pop() -} - -func modelStatusColor(valid bool, selected bool) *tcell.Color { - if valid { - color := tview.Styles.PrimaryTextColor - return &color - } - color := tcell.ColorGray - return &color -} - -func refreshModelMenu(menu *Menu, currentModel string, models []*picoclawconfig.ModelConfig) { - for i, model := range models { - row := i - label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) - isValid := isModelValid(model) - if model.ModelName == currentModel && currentModel != "" { - label = "* " + label - } - cell := menu.GetCell(row, 0) - if cell != nil { - cell.SetText(label) - isSelected := model.ModelName == currentModel && currentModel != "" - color := modelStatusColor(isValid, isSelected) - if color != nil { - cell.SetTextColor(*color) - } - } - } -} - -func refreshModelMenuFromState(menu *Menu, s *appState) { - items := make([]MenuItem, 0, 1+len(s.config.ModelList)) - currentModel := strings.TrimSpace(s.config.Agents.Defaults.ModelName) - for i := range s.config.ModelList { - index := i - model := s.config.ModelList[i] - isValid := isModelValid(model) - desc := model.APIBase - if desc == "" { - desc = model.AuthMethod - } - if desc == "" { - desc = "api_key required" - } - label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) - if model.ModelName == currentModel && currentModel != "" { - label = "* " + label - } - isSelected := model.ModelName == currentModel && currentModel != "" - items = append(items, MenuItem{ - Label: label, - Description: desc, - MainColor: modelStatusColor(isValid, isSelected), - Action: func() { - s.push(fmt.Sprintf("model-%d", index), s.modelForm(index)) - }, - }) - } - items = append(items, - MenuItem{ - Label: "**Add Model**", - Description: "Append a new model entry", - Action: func() { - newName := s.nextAvailableModelName("new-model") - s.addModel( - &picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"}, - ) - s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1)) - }, - }, - ) - menu.applyItems(items) -} - -func isModelValid(model *picoclawconfig.ModelConfig) bool { - hasKey := strings.TrimSpace(model.APIKey()) != "" || - strings.TrimSpace(model.AuthMethod) == "oauth" - hasModel := strings.TrimSpace(model.Model) != "" - return hasKey && hasModel -} - -func (s *appState) modelNameExists(name string, excludeIndex int) bool { - target := strings.TrimSpace(name) - if target == "" { - return false - } - for i := range s.config.ModelList { - if i == excludeIndex { - continue - } - if strings.TrimSpace(s.config.ModelList[i].ModelName) == target { - return true - } - } - return false -} - -func (s *appState) nextAvailableModelName(base string) string { - name := strings.TrimSpace(base) - if name == "" { - name = "new-model" - } - if !s.modelNameExists(name, -1) { - return name - } - for i := 2; ; i++ { - candidate := fmt.Sprintf("%s-%d", name, i) - if !s.modelNameExists(candidate, -1) { - return candidate - } - } -} - -func (s *appState) testModel(model *picoclawconfig.ModelConfig) { - if model == nil { - return - } - if strings.TrimSpace(model.APIKey()) == "" { - s.showMessage("Missing API Key", "Set api_key before testing") - return - } - base := strings.TrimSpace(model.APIBase) - if base == "" { - s.showMessage("Missing API Base", "Set api_base before testing") - return - } - modelID := strings.TrimSpace(model.Model) - if modelID == "" { - s.showMessage("Missing Model", "Set model before testing") - return - } - if !strings.HasPrefix(modelID, "openai/") { - s.showMessage("Unsupported model", "Only openai/* models are supported for test") - return - } - modelName := strings.TrimPrefix(modelID, "openai/") - endpoint := strings.TrimRight(base, "/") + "/chat/completions" - - payload := fmt.Sprintf( - `{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`, - modelName, - ) - client := &http.Client{Timeout: 10 * time.Second} - request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload)) - if err != nil { - s.showMessage("Test failed", err.Error()) - return - } - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey())) - - resp, err := client.Do(request) - if err != nil { - s.showMessage("Test failed", err.Error()) - return - } - defer resp.Body.Close() - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - s.showMessage("Test OK", resp.Status) - return - } - body, err := io.ReadAll(io.LimitReader(resp.Body, 2048)) - if err != nil { - s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err)) - return - } - s.showMessage( - "Test failed", - fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))), - ) -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/style.go b/cmd/picoclaw-launcher-tui/internal/ui/style.go deleted file mode 100644 index da3c3526d..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/style.go +++ /dev/null @@ -1,55 +0,0 @@ -package ui - -import ( - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -const ( - colorBlue = "[#3e5db9]" - colorRed = "[#d54646]" - banner = "\r\n[::b]" + - colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" + - colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" + - colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" + - colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" + - colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + - colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " + - "[:]" -) - -func applyStyles() { - tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22) - tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53) - tview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32) - tview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255) - tview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198) - tview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253) - tview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255) - tview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123) - tview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253) - tview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22) - tview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249) -} - -func bannerView() *tview.TextView { - text := tview.NewTextView() - text.SetDynamicColors(true) - text.SetTextAlign(tview.AlignCenter) - text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor) - text.SetText(banner) - text.SetBorder(false) - return text -} - -const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch" - -func footerView() *tview.TextView { - text := tview.NewTextView() - text.SetTextAlign(tview.AlignCenter) - text.SetText(footerText) - text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor) - text.SetTextColor(tview.Styles.PrimaryTextColor) - text.SetBorder(false) - return text -} diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go index 0e8cce415..3cb7110c1 100644 --- a/cmd/picoclaw-launcher-tui/main.go +++ b/cmd/picoclaw-launcher-tui/main.go @@ -1,15 +1,48 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + package main import ( "fmt" "os" + "os/exec" + "path/filepath" - "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui" + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" + "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui" ) func main() { - if err := ui.Run(); err != nil { - fmt.Fprintln(os.Stderr, err) + configPath := tuicfg.DefaultConfigPath() + if len(os.Args) > 1 { + configPath = os.Args[1] + } + + configDir := filepath.Dir(configPath) + if _, err := os.Stat(configDir); os.IsNotExist(err) { + cmd := exec.Command("picoclaw", "onboard") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() + } + + cfg, err := tuicfg.Load(configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err) + os.Exit(1) + } + + app := ui.New(cfg, configPath) + // Bind model selection hook to sync to main config + app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) { + _ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID) + } + if err := app.Run(); err != nil { + fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err) os.Exit(1) } } diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go new file mode 100644 index 000000000..a65693b01 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/app.go @@ -0,0 +1,325 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "fmt" + "sync" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" +) + +// App is the root TUI application. +type App struct { + tapp *tview.Application + pages *tview.Pages + pageStack []string + cfg *tuicfg.TUIConfig + configPath string + pageRefreshFns map[string]func() + headerModelTV *tview.TextView + modalOpen map[string]bool + + // OnModelSelected is called when a model is selected in the UI. + // Can be nil to disable. + OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) + + modelCache map[string][]modelEntry + modelCacheMu sync.RWMutex + refreshMu sync.Mutex +} + +// cacheKey returns the map key for a (scheme, user) pair. +func cacheKey(schemeName, userName string) string { + return fmt.Sprintf("%s/%s", schemeName, userName) +} + +// cachedModels returns a defensive copy of the cached model list for a user (may be nil). +func (a *App) cachedModels(schemeName, userName string) []modelEntry { + a.modelCacheMu.RLock() + defer a.modelCacheMu.RUnlock() + entries := a.modelCache[cacheKey(schemeName, userName)] + return append([]modelEntry(nil), entries...) +} + +// refreshModelCache fetches models for every user in the config concurrently. +// Serialized by refreshMu so concurrent calls don't race on the cache map. +// When all fetches complete it calls onDone via QueueUpdateDraw. +func (a *App) refreshModelCache(onDone func()) { + go func() { + a.refreshMu.Lock() + defer a.refreshMu.Unlock() + + users := a.cfg.Provider.Users + schemes := a.cfg.Provider.Schemes + + schemeURL := make(map[string]string, len(schemes)) + for _, s := range schemes { + schemeURL[s.Name] = s.BaseURL + } + + var wg sync.WaitGroup + for _, u := range users { + baseURL, ok := schemeURL[u.Scheme] + if !ok || baseURL == "" { + continue + } + if u.Key == "" { + a.modelCacheMu.Lock() + if a.modelCache == nil { + a.modelCache = make(map[string][]modelEntry) + } + a.modelCache[cacheKey(u.Scheme, u.Name)] = nil + a.modelCacheMu.Unlock() + continue + } + wg.Add(1) + bURL := baseURL + go func() { + defer wg.Done() + entries, err := fetchModels(bURL, u.Key) + a.modelCacheMu.Lock() + if a.modelCache == nil { + a.modelCache = make(map[string][]modelEntry) + } + if err != nil || len(entries) == 0 { + a.modelCache[cacheKey(u.Scheme, u.Name)] = nil + } else { + a.modelCache[cacheKey(u.Scheme, u.Name)] = entries + } + a.modelCacheMu.Unlock() + }() + } + wg.Wait() + + if onDone != nil { + a.tapp.QueueUpdateDraw(onDone) + } + }() +} + +// New creates and wires up the TUI application. +func New(cfg *tuicfg.TUIConfig, configPath string) *App { + // Cyberpunk Theme Colors + // Dark background + tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void + tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo + tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40) + + // Borders and Titles + tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan + tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan + tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta + + // Text + tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white + tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan + tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime + tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black + tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta + + a := &App{ + tapp: tview.NewApplication(), + pages: tview.NewPages(), + pageStack: []string{}, + cfg: cfg, + configPath: configPath, + pageRefreshFns: make(map[string]func()), + modalOpen: make(map[string]bool), + } + + a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + if len(a.modalOpen) > 0 { + return event + } + return a.goBack() + } + return event + }) + + a.buildPages() + return a +} + +// Run starts the TUI event loop. +func (a *App) Run() error { + return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run() +} + +func (a *App) buildPages() { + a.pages.AddPage("home", a.newHomePage(), true, true) + a.pageStack = []string{"home"} +} + +func (a *App) navigateTo(name string, page tview.Primitive) { + a.pages.RemovePage(name) + a.pages.AddPage(name, page, true, false) + a.pageStack = append(a.pageStack, name) + a.pages.SwitchToPage(name) +} + +func (a *App) goBack() *tcell.EventKey { + if len(a.pageStack) <= 1 { + return nil + } + popped := a.pageStack[len(a.pageStack)-1] + a.pageStack = a.pageStack[:len(a.pageStack)-1] + a.pages.RemovePage(popped) + prev := a.pageStack[len(a.pageStack)-1] + if fn, ok := a.pageRefreshFns[prev]; ok { + fn() + } + if prev == "home" && a.headerModelTV != nil { + a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ") + } + a.pages.SwitchToPage(prev) + return nil +} + +func (a *App) showModal(name string, primitive tview.Primitive) { + a.modalOpen[name] = true + a.pages.AddPage(name, primitive, true, true) +} + +func (a *App) hideModal(name string) { + delete(a.modalOpen, name) + a.pages.HidePage(name) + a.pages.RemovePage(name) +} + +func (a *App) save() { + if err := tuicfg.Save(a.configPath, a.cfg); err != nil { + a.showError("save failed: " + err.Error()) + } +} + +func (a *App) showError(msg string) { + modal := tview.NewModal(). + SetText(" [red::b]ERROR[-::-]\n\n" + msg). + AddButtons([]string{"OK"}). + SetDoneFunc(func(_ int, _ string) { + a.hideModal("error") + }) + // Cyberpunk Modal Style + modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo + modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White + modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red + modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White + a.showModal("error", modal) +} + +func (a *App) confirmDelete(label string, onConfirm func()) { + modal := tview.NewModal(). + SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]"). + AddButtons([]string{"Delete", "Cancel"}). + SetDoneFunc(func(_ int, buttonLabel string) { + a.hideModal("confirm-delete") + if buttonLabel == "Delete" { + onConfirm() + } + }) + // Cyberpunk Modal Style + modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo + modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White + modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger + modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White + a.showModal("confirm-delete", modal) +} + +func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive { + return tview.NewFlex(). + AddItem(tview.NewBox(), 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(tview.NewBox(), 0, 1, false). + AddItem(form, height, 1, true). + AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true). + AddItem(tview.NewBox(), 0, 1, false) +} + +func hintBar(text string) *tview.TextView { + tv := tview.NewTextView(). + SetText(text). + SetDynamicColors(true). + SetTextAlign(tview.AlignCenter). + SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan + tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo + return tv +} + +func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive { + var modelTV *tview.TextView + if pageID == "home" { + if a.headerModelTV == nil { + a.headerModelTV = tview.NewTextView() + a.headerModelTV.SetTextAlign(tview.AlignRight). + SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime + SetDynamicColors(true). + SetBackgroundColor(tcell.NewHexColor(0x050510)) + } + modelTV = a.headerModelTV + modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ") + } else { + modelTV = tview.NewTextView() + modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510)) + } + + headerLeft := tview.NewTextView(). + SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///"). + SetDynamicColors(true). + SetBackgroundColor(tcell.NewHexColor(0x050510)) + + header := tview.NewFlex(). + AddItem(headerLeft, 0, 1, false). + AddItem(modelTV, 0, 1, false) + + sidebar := tview.NewTextView(). + SetDynamicColors(true). + SetWrap(false) + sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo + + // Cyberpunk Sidebar Styling + activePrefix := "[#39ff14::b]>> " // Neon Lime arrow + activeSuffix := "[-]" + inactivePrefix := "[#808080] " + inactiveSuffix := "[-]" + + sbText := "\n\n" // Top padding + + menuItem := func(id, label string) string { + if pageID == id { + return activePrefix + label + activeSuffix + "\n\n" + } + return inactivePrefix + label + inactiveSuffix + "\n\n" + } + + sbText += menuItem("home", "HOME") + sbText += menuItem("schemes", "SCHEMES") + sbText += menuItem("users", "USERS") + sbText += menuItem("models", "MODELS") + sbText += menuItem("channels", "CHANNELS") + sbText += menuItem("gateway", "GATEWAY") + + sidebar.SetText(sbText) + + footer := hintBar(hint) + + grid := tview.NewGrid(). + SetRows(1, 0, 1). + SetColumns(20, 0). // Slightly wider sidebar + AddItem(header, 0, 0, 1, 2, 0, 0, false). + AddItem(sidebar, 1, 0, 1, 1, 0, 0, false). + AddItem(content, 1, 1, 1, 1, 0, 0, true). + AddItem(footer, 2, 0, 1, 2, 0, 0, false) + + // Add a border around the content area if possible, or ensure content has its own border + // grid.SetBorders(false) // Grid borders usually look bad, handled by components + + return grid +} diff --git a/cmd/picoclaw-launcher-tui/ui/channels.go b/cmd/picoclaw-launcher-tui/ui/channels.go new file mode 100644 index 000000000..c976f1fcd --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/channels.go @@ -0,0 +1,202 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "strconv" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (a *App) newChannelsPage() tview.Primitive { + list := tview.NewList() + list.SetBorder(true). + SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS "). + SetTitleColor(tcell.NewHexColor(0x00f0ff)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) + list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0)) + list.SetSecondaryTextColor(tcell.NewHexColor(0x808080)) + list.SetSelectedStyle( + tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)), + ) + list.SetHighlightFullLine(true) + list.SetBackgroundColor(tcell.NewHexColor(0x050510)) + + rebuild := func() { + sel := list.GetCurrentItem() + list.Clear() + + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + configPath := filepath.Join(home, ".picoclaw", "config.json") + + var cfg map[string]any + if data, err := os.ReadFile(configPath); err == nil { + _ = json.Unmarshal(data, &cfg) + } + + if chRaw, ok := cfg["channels"].(map[string]any); ok { + for name, ch := range chRaw { + chMap, ok := ch.(map[string]any) + enabled := "disabled" + if ok { + if e, ok := chMap["enabled"].(bool); ok && e { + enabled = "enabled" + } + } + list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() { + a.showChannelEditForm(configPath, name, chMap) + }) + } + } + + if sel >= 0 && sel < list.GetItemCount() { + list.SetCurrentItem(sel) + } + } + rebuild() + + a.pageRefreshFns["channels"] = rebuild + + list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + return a.goBack() + } + return event + }) + + return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ") +} + +func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) { + form := tview.NewForm() + form.SetBorder(true). + SetTitle(" [::b]EDIT CHANNEL "). + SetTitleColor(tcell.NewHexColor(0x39ff14)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) + form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) + form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) + form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) + form.SetLabelColor(tcell.NewHexColor(0xe0e0e0)) + form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff)) + form.SetButtonTextColor(tcell.NewHexColor(0xffffff)) + + fields := make(map[string]*tview.InputField) + var nameField *tview.InputField + + if channelName == "" { + nameField = tview.NewInputField(). + SetLabel("Channel Name"). + SetText(""). + SetFieldWidth(28) + form.AddFormItem(nameField) + } + + for k, v := range existing { + if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice { + continue + } + valStr := fmt.Sprintf("%v", v) + field := tview.NewInputField(). + SetLabel(k). + SetText(valStr). + SetFieldWidth(28) + form.AddFormItem(field) + fields[k] = field + } + + form.AddButton("SAVE", func() { + var cfg map[string]any + if data, err := os.ReadFile(configPath); err == nil { + if err := json.Unmarshal(data, &cfg); err != nil { + cfg = make(map[string]any) + } + } else { + cfg = make(map[string]any) + } + + if _, ok := cfg["channels"]; !ok { + cfg["channels"] = make(map[string]any) + } + channels, ok := cfg["channels"].(map[string]any) + if !ok { + channels = make(map[string]any) + cfg["channels"] = channels + } + + finalName := channelName + if channelName == "" { + if nameField == nil || nameField.GetText() == "" { + a.showError("Channel name is required") + return + } + finalName = nameField.GetText() + } + + updated := make(map[string]any) + if existing != nil { + for k, v := range existing { + updated[k] = v + } + } + for k, field := range fields { + val := field.GetText() + if val == "true" { + updated[k] = true + } else if val == "false" { + updated[k] = false + } else if num, err := strconv.Atoi(val); err == nil { + updated[k] = num + } else { + updated[k] = val + } + } + + if channelName != "" && finalName != channelName { + delete(channels, channelName) + } + channels[finalName] = updated + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + a.showError(fmt.Sprintf("Failed to save config: %v", err)) + return + } + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + a.showError(fmt.Sprintf("Failed to create config directory: %v", err)) + return + } + if err := os.WriteFile(configPath, data, 0o600); err != nil { + a.showError(fmt.Sprintf("Failed to write config: %v", err)) + return + } + + a.hideModal("channel-edit") + a.goBack() + }) + + form.AddButton("CANCEL", func() { + a.hideModal("channel-edit") + }) + + form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + a.hideModal("channel-edit") + return nil + } + return event + }) + + a.showModal("channel-edit", centeredForm(form, 4, 20)) +} diff --git a/cmd/picoclaw-launcher-tui/ui/gateway.go b/cmd/picoclaw-launcher-tui/ui/gateway.go new file mode 100644 index 000000000..1138c12db --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/gateway.go @@ -0,0 +1,261 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +const pidFileName = "gateway.pid" + +type gatewayStatus struct { + running bool + pid int +} + +func getPidPath() string { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + return filepath.Join(home, ".picoclaw", pidFileName) +} + +func isProcessRunning(pid int) bool { + if runtime.GOOS == "windows" { + cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid)) + output, err := cmd.Output() + if err != nil { + return false + } + return strings.Contains(string(output), strconv.Itoa(pid)) + } else if runtime.GOOS == "darwin" { + cmd := exec.Command("ps", "aux") + output, err := cmd.Output() + if err != nil { + return false + } + return strings.Contains(string(output), fmt.Sprintf(" %d ", pid)) + } + // Linux + _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)) + return err == nil +} + +func getGatewayStatus() gatewayStatus { + pidPath := getPidPath() + data, err := os.ReadFile(pidPath) + if err != nil { + return gatewayStatus{running: false} + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + return gatewayStatus{running: false} + } + if !isProcessRunning(pid) { + os.Remove(pidPath) + return gatewayStatus{running: false} + } + return gatewayStatus{ + running: true, + pid: pid, + } +} + +func startGateway() error { + status := getGatewayStatus() + if status.running { + return fmt.Errorf("gateway is already running (PID: %d)", status.pid) + } + + pidPath := getPidPath() + var cmd *exec.Cmd + + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1") + } else { + cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 & echo $! > "+pidPath) + } + + err := cmd.Start() + if err != nil { + return err + } + + time.Sleep(1 * time.Second) + + if runtime.GOOS == "windows" { + cmd := exec.Command( + "wmic", + "process", + "where", + "name='picoclaw.exe' and commandline like '%gateway%'", + "get", + "processid", + ) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to get gateway PID: %w", err) + } + lines := strings.Split(string(output), "\n") + for _, line := range lines[1:] { + line = strings.TrimSpace(line) + if line == "" { + continue + } + pid, err := strconv.Atoi(line) + if err == nil { + os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0o600) + break + } + } + } + + status = getGatewayStatus() + if !status.running { + return fmt.Errorf("failed to start gateway") + } + return nil +} + +func stopGateway() error { + status := getGatewayStatus() + if !status.running { + return fmt.Errorf("gateway is not running") + } + + var err error + if runtime.GOOS == "windows" { + err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run() + } else { + err = exec.Command("kill", "-9", strconv.Itoa(status.pid)).Run() + } + if err != nil { + return err + } + + // 多次尝试确认进程已停止 + for i := 0; i < 5; i++ { + if !isProcessRunning(status.pid) { + break + } + time.Sleep(200 * time.Millisecond) + } + + os.Remove(getPidPath()) + return nil +} + +func (a *App) newGatewayPage() tview.Primitive { + flex := tview.NewFlex().SetDirection(tview.FlexRow) + flex.SetBorder(true). + SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT "). + SetTitleColor(tcell.NewHexColor(0x00f0ff)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) + flex.SetBackgroundColor(tcell.NewHexColor(0x050510)) + + statusTV := tview.NewTextView(). + SetDynamicColors(true). + SetTextAlign(tview.AlignCenter). + SetText("Checking status...") + statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510)) + + var updateStatus func() + + // 使用List作为按钮,保证显示和交互正常 + buttons := tview.NewList() + buttons.SetBackgroundColor(tcell.NewHexColor(0x050510)) + buttons.SetMainTextColor(tcell.ColorWhite) + buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff)) + buttons.SetSelectedTextColor(tcell.ColorBlack) + + buttons.AddItem(" [lime]START[white] ", "", 0, func() { + if !getGatewayStatus().running { + err := startGateway() + if err != nil { + a.showError(err.Error()) + } + updateStatus() + } + }) + buttons.AddItem(" [red]STOP[white] ", "", 0, func() { + if getGatewayStatus().running { + err := stopGateway() + if err != nil { + a.showError(err.Error()) + } + updateStatus() + } + }) + + buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn) + buttonFlex. + AddItem(tview.NewBox(), 0, 1, false). + AddItem(buttons, 20, 1, true). + AddItem(tview.NewBox(), 0, 1, false) + + flex. + AddItem(tview.NewBox(), 0, 1, false). + AddItem(statusTV, 3, 1, false). + AddItem(tview.NewBox(), 0, 1, false). + AddItem(buttonFlex, 4, 1, true). + AddItem(tview.NewBox(), 0, 1, false) + + updateStatus = func() { + status := getGatewayStatus() + if status.running { + statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d", status.pid)) + buttons.SetItemText(0, " [gray]START[white] ", "") + buttons.SetItemText(1, " [red]STOP[white] ", "") + } else { + statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A") + buttons.SetItemText(0, " [lime]START[white] ", "") + buttons.SetItemText(1, " [gray]STOP[white] ", "") + } + } + + updateStatus() + + done := make(chan struct{}) + go func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + a.tapp.QueueUpdateDraw(updateStatus) + case <-done: + return + } + } + }() + + originalInputCapture := flex.GetInputCapture() + flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + close(done) + return a.goBack() + } + if originalInputCapture != nil { + return originalInputCapture(event) + } + return event + }) + + a.pageRefreshFns["gateway"] = updateStatus + + return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ") +} diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go new file mode 100644 index 000000000..74a7769cf --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/home.go @@ -0,0 +1,70 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "os" + "os/exec" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (a *App) newHomePage() tview.Primitive { + list := tview.NewList() + list.SetBorder(true). + SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION "). + SetTitleColor(tcell.NewHexColor(0x00f0ff)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) + list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0)) + list.SetSecondaryTextColor(tcell.NewHexColor(0x808080)) + list.SetSelectedStyle( + tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)), + ) + list.SetHighlightFullLine(true) + list.SetBackgroundColor(tcell.NewHexColor(0x050510)) + + rebuildList := func() { + sel := list.GetCurrentItem() + list.Clear() + list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() { + a.navigateTo("schemes", a.newSchemesPage()) + }) + list.AddItem( + "CHANNELS: Configure communication channels", + "Manage Telegram/Discord/WeChat channels", + 'n', + func() { + a.navigateTo("channels", a.newChannelsPage()) + }, + ) + list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() { + a.navigateTo("gateway", a.newGatewayPage()) + }) + list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() { + a.tapp.Suspend(func() { + cmd := exec.Command("picoclaw", "agent") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() + }) + }) + list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() }) + if sel >= 0 && sel < list.GetItemCount() { + list.SetCurrentItem(sel) + } + } + rebuildList() + + a.pageRefreshFns["home"] = rebuildList + + return a.buildShell( + "home", + list, + " [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ", + ) +} diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go new file mode 100644 index 000000000..20e5f0182 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/models.go @@ -0,0 +1,200 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" +) + +type modelsAPIResponse struct { + Data []modelEntry `json:"data"` +} + +type modelEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive { + table := tview.NewTable(). + SetBorders(false). + SetSelectable(true, false). + SetFixed(0, 0) + table.SetBorder(true). + SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)). + SetTitleColor(tcell.NewHexColor(0x00f0ff)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) + table.SetSelectedStyle( + tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)), + ) + table.SetBackgroundColor(tcell.NewHexColor(0x050510)) + + var modelIDs []string + + status := tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetDynamicColors(true). + SetText("[#ffff00]FETCHING MODELS...[-]") + status.SetBackgroundColor(tcell.NewHexColor(0x050510)) + + flex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(status, 1, 0, false). + AddItem(table, 0, 1, false) + + apiKey := a.resolveKey(schemeName, userName) + + go func() { + var entries []modelEntry + var err error + if apiKey == "" { + err = fmt.Errorf("key is required") + } else { + entries, err = fetchModels(baseURL, apiKey) + } + + a.modelCacheMu.Lock() + if a.modelCache == nil { + a.modelCache = make(map[string][]modelEntry) + } + if err == nil && len(entries) > 0 { + a.modelCache[cacheKey(schemeName, userName)] = entries + } else { + a.modelCache[cacheKey(schemeName, userName)] = nil + } + a.modelCacheMu.Unlock() + + a.tapp.QueueUpdateDraw(func() { + if err != nil { + status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error())) + table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)")) + a.tapp.SetFocus(table) + return + } + if len(entries) == 0 { + status.SetText("[#ff2a2a]NO MODELS RETURNED[-]") + table.SetCell(0, 0, tview.NewTableCell(" (no models available)")) + a.tapp.SetFocus(table) + return + } + + status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries))) + for i, m := range entries { + modelIDs = append(modelIDs, m.ID) + table.SetCell(i, 0, + tview.NewTableCell(fmt.Sprintf("%3d", i+1)). + SetAlign(tview.AlignRight). + SetTextColor(tcell.NewHexColor(0x808080)). + SetSelectable(false), + ) + table.SetCell(i, 1, + tview.NewTableCell(" "+m.ID). + SetAlign(tview.AlignLeft). + SetExpansion(1). + SetTextColor(tcell.NewHexColor(0xe0e0e0)), + ) + } + a.tapp.SetFocus(table) + }) + }() + + table.SetSelectedFunc(func(row, _ int) { + if row < 0 || row >= len(modelIDs) { + return + } + a.cfg.Provider.Current = tuicfg.ProviderCurrent{ + Scheme: schemeName, + User: userName, + Model: modelIDs[row], + } + a.save() + + // Trigger model selected callback if set + if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" { + scheme := a.cfg.Provider.SchemeByName(schemeName) + if scheme == nil { + a.goBack() + return + } + var user tuicfg.User + for _, u := range a.cfg.Provider.Users { + if u.Scheme == schemeName && u.Name == userName { + user = u + break + } + } + a.OnModelSelected(*scheme, user, modelIDs[row]) + } + + a.goBack() + }) + + return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ") +} + +func (a *App) resolveKey(schemeName, userName string) string { + for _, u := range a.cfg.Provider.Users { + if u.Scheme == schemeName && u.Name == userName { + return u.Key + } + } + return "" +} + +func fetchModels(baseURL, apiKey string) ([]modelEntry, error) { + url := strings.TrimRight(baseURL, "/") + "/models" + + client := &http.Client{Timeout: 15 * time.Second} + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + var result modelsAPIResponse + if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 { + return result.Data, nil + } + + var arr []modelEntry + if err := json.Unmarshal(body, &arr); err == nil { + return arr, nil + } + + return nil, fmt.Errorf( + "decode response: unrecognized shape: %s", + strings.TrimSpace(string(body[:min(len(body), 256)])), + ) +} diff --git a/cmd/picoclaw-launcher-tui/ui/schemes.go b/cmd/picoclaw-launcher-tui/ui/schemes.go new file mode 100644 index 000000000..e38d7fa86 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/schemes.go @@ -0,0 +1,252 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" +) + +func (a *App) newSchemesPage() tview.Primitive { + table := tview.NewTable(). + SetBorders(false). + SetSelectable(true, false) + table.SetBorder(true). + SetTitle(" [#00f0ff::b] PROVIDER SCHEMES "). + SetTitleColor(tcell.NewHexColor(0x00f0ff)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) + table.SetSelectedStyle( + tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)), + ) + table.SetBackgroundColor(tcell.NewHexColor(0x050510)) + + rowToIdx := func(row int) int { return row / 2 } + + selectedSchemeName := func() string { + row, _ := table.GetSelection() + idx := rowToIdx(row) + schemes := a.cfg.Provider.Schemes + if idx >= 0 && idx < len(schemes) { + return schemes[idx].Name + } + return "" + } + + rebuild := func() { + selName := selectedSchemeName() + table.Clear() + schemes := a.cfg.Provider.Schemes + for i, s := range schemes { + nameRow := i * 2 + detailRow := nameRow + 1 + + table.SetCell(nameRow, 0, + tview.NewTableCell(" "+s.Name). + SetTextColor(tcell.NewHexColor(0xe0e0e0)). + SetExpansion(1). + SetSelectable(true), + ) + + users := a.cfg.Provider.UsersForScheme(s.Name) + n := len(users) + m := 0 + for _, u := range users { + if models := a.cachedModels(s.Name, u.Name); len(models) > 0 { + m++ + } + } + table.SetCell(detailRow, 0, + tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)). + SetTextColor(tcell.NewHexColor(0x808080)). + SetExpansion(1). + SetSelectable(false), + ) + table.SetCell(detailRow, 1, + tview.NewTableCell("[#00f0ff]"+s.Type+" "). + SetAlign(tview.AlignRight). + SetSelectable(false), + ) + } + if selName != "" { + for i, s := range schemes { + if s.Name == selName { + table.Select(i*2, 0) + return + } + } + } + if table.GetRowCount() > 0 { + table.Select(0, 0) + } + } + rebuild() + + a.refreshModelCache(rebuild) + a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) } + + table.SetSelectedFunc(func(row, _ int) { + idx := rowToIdx(row) + schemes := a.cfg.Provider.Schemes + if idx < 0 || idx >= len(schemes) { + return + } + name := schemes[idx].Name + a.navigateTo("users", a.newUsersPage(name)) + }) + + table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + row, _ := table.GetSelection() + idx := rowToIdx(row) + schemes := a.cfg.Provider.Schemes + switch event.Rune() { + case 'a': + a.showSchemeForm(nil, func(s tuicfg.Scheme) { + a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s) + a.save() + a.refreshModelCache(rebuild) + }) + return nil + case 'e': + if idx < 0 || idx >= len(schemes) { + return nil + } + origName := schemes[idx].Name + orig := schemes[idx] + a.showSchemeForm(&orig, func(s tuicfg.Scheme) { + current := a.cfg.Provider.Schemes + for i, sc := range current { + if sc.Name == origName { + a.cfg.Provider.Schemes[i] = s + break + } + } + a.save() + a.refreshModelCache(func() { + rebuild() + for i, sc := range a.cfg.Provider.Schemes { + if sc.Name == s.Name { + table.Select(i*2, 0) + break + } + } + }) + }) + return nil + case 'd': + if idx < 0 || idx >= len(schemes) { + return nil + } + name := schemes[idx].Name + a.confirmDelete(fmt.Sprintf("scheme %q", name), func() { + current := a.cfg.Provider.Schemes + newSchemes := make([]tuicfg.Scheme, 0, len(current)) + for _, sc := range current { + if sc.Name != name { + newSchemes = append(newSchemes, sc) + } + } + a.cfg.Provider.Schemes = newSchemes + + existing := a.cfg.Provider.Users + filtered := make([]tuicfg.User, 0, len(existing)) + for _, u := range existing { + if u.Scheme != name { + filtered = append(filtered, u) + } + } + a.cfg.Provider.Users = filtered + + a.save() + a.refreshModelCache(rebuild) + }) + return nil + } + return event + }) + + return a.buildShell( + "schemes", + table, + " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ", + ) +} + +func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) { + name := "" + baseURL := "" + schemeType := "openai-compatible" + title := " ADD SCHEME " + + if existing != nil { + name = existing.Name + baseURL = existing.BaseURL + schemeType = existing.Type + title = " EDIT SCHEME " + } + + typeOptions := []string{"openai-compatible", "anthropic"} + typeIdx := 0 + for i, t := range typeOptions { + if t == schemeType { + typeIdx = i + break + } + } + + form := tview.NewForm() + + form. + AddInputField("Name", name, 20, nil, func(text string) { name = text }). + AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }). + AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }). + AddButton("SAVE", func() { + if name == "" { + a.showError("Name is required") + return + } + if baseURL == "" { + a.showError("Base URL is required") + return + } + if existing == nil { + for _, s := range a.cfg.Provider.Schemes { + if s.Name == name { + a.showError(fmt.Sprintf("Scheme name %q already exists", name)) + return + } + } + } + a.hideModal("scheme-form") + onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType}) + }). + AddButton("CANCEL", func() { + a.hideModal("scheme-form") + }) + + form.SetBorder(true). + SetTitle(" [::b]" + title + " "). + SetTitleColor(tcell.NewHexColor(0x39ff14)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) + form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) + form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) + form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) + form.SetLabelColor(tcell.NewHexColor(0xe0e0e0)) + form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff)) + form.SetButtonTextColor(tcell.NewHexColor(0xffffff)) + form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + a.hideModal("scheme-form") + return nil + } + return event + }) + + a.showModal("scheme-form", centeredForm(form, 4, 12)) +} diff --git a/cmd/picoclaw-launcher-tui/ui/users.go b/cmd/picoclaw-launcher-tui/ui/users.go new file mode 100644 index 000000000..b00fc8982 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/users.go @@ -0,0 +1,261 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" +) + +func (a *App) newUsersPage(schemeName string) tview.Primitive { + table := tview.NewTable(). + SetBorders(false). + SetSelectable(true, false) + table.SetBorder(true). + SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)). + SetTitleColor(tcell.NewHexColor(0x00f0ff)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) + table.SetSelectedStyle( + tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)), + ) + table.SetBackgroundColor(tcell.NewHexColor(0x050510)) + + visibleUsers := func() []tuicfg.User { + var out []tuicfg.User + for _, u := range a.cfg.Provider.Users { + if u.Scheme == schemeName { + out = append(out, u) + } + } + return out + } + + findUserGlobalIdx := func(userName string) int { + for i, u := range a.cfg.Provider.Users { + if u.Scheme == schemeName && u.Name == userName { + return i + } + } + return -1 + } + + rowToVisIdx := func(row int) int { return row / 2 } + + selectedUserName := func() string { + row, _ := table.GetSelection() + users := visibleUsers() + visIdx := rowToVisIdx(row) + if visIdx >= 0 && visIdx < len(users) { + return users[visIdx].Name + } + return "" + } + + rebuild := func() { + selName := selectedUserName() + table.Clear() + users := visibleUsers() + for i, u := range users { + nameRow := i * 2 + detailRow := nameRow + 1 + + table.SetCell(nameRow, 0, + tview.NewTableCell(" "+u.Name). + SetTextColor(tcell.NewHexColor(0xe0e0e0)). + SetExpansion(1). + SetSelectable(true), + ) + table.SetCell(nameRow, 1, + tview.NewTableCell(""). + SetSelectable(false), + ) + + models := a.cachedModels(schemeName, u.Name) + var detailText string + if len(models) > 0 { + detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models)) + } else { + detailText = " [#ff2a2a]Inactive / No Access[-]" + } + table.SetCell(detailRow, 0, + tview.NewTableCell(detailText). + SetTextColor(tcell.NewHexColor(0x808080)). + SetExpansion(1). + SetSelectable(false), + ) + table.SetCell(detailRow, 1, + tview.NewTableCell("[#00f0ff]"+u.Type+" "). + SetAlign(tview.AlignRight). + SetSelectable(false), + ) + } + if selName != "" { + for i, u := range users { + if u.Name == selName { + table.Select(i*2, 0) + return + } + } + } + if table.GetRowCount() > 0 { + table.Select(0, 0) + } + } + rebuild() + + a.refreshModelCache(rebuild) + a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) } + + table.SetSelectedFunc(func(row, _ int) { + visIdx := rowToVisIdx(row) + users := visibleUsers() + if visIdx < 0 || visIdx >= len(users) { + return + } + uName := users[visIdx].Name + scheme := a.cfg.Provider.SchemeByName(schemeName) + if scheme == nil { + a.showError(fmt.Sprintf("Scheme %q not found", schemeName)) + return + } + a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL)) + }) + + table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + row, _ := table.GetSelection() + visIdx := rowToVisIdx(row) + users := visibleUsers() + switch event.Rune() { + case 'a': + a.showUserForm(schemeName, nil, func(u tuicfg.User) { + a.cfg.Provider.Users = append(a.cfg.Provider.Users, u) + a.save() + a.refreshModelCache(rebuild) + }) + return nil + case 'e': + if visIdx < 0 || visIdx >= len(users) { + return nil + } + origName := users[visIdx].Name + orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)] + a.showUserForm(schemeName, &orig, func(u tuicfg.User) { + cfgIdx := findUserGlobalIdx(origName) + if cfgIdx < 0 { + a.showError(fmt.Sprintf("User %q no longer exists", origName)) + return + } + a.cfg.Provider.Users[cfgIdx] = u + a.save() + a.refreshModelCache(func() { + rebuild() + for i, usr := range visibleUsers() { + if usr.Name == u.Name { + table.Select(i*2, 0) + break + } + } + }) + }) + return nil + case 'd': + if visIdx < 0 || visIdx >= len(users) { + return nil + } + uName := users[visIdx].Name + a.confirmDelete(fmt.Sprintf("user %q", uName), func() { + cfgIdx := findUserGlobalIdx(uName) + if cfgIdx < 0 { + return + } + all := a.cfg.Provider.Users + a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...) + a.save() + a.refreshModelCache(rebuild) + }) + return nil + } + return event + }) + + return a.buildShell( + "users", + table, + " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ", + ) +} + +func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) { + name := "" + userType := "key" + key := "" + title := " ADD USER " + + if existing != nil { + name = existing.Name + userType = existing.Type + key = existing.Key + title = " EDIT USER " + } + + typeOptions := []string{"key", "OAuth"} + typeIdx := 0 + for i, t := range typeOptions { + if t == userType { + typeIdx = i + break + } + } + + form := tview.NewForm() + form. + AddInputField("Name", name, 20, nil, func(text string) { name = text }). + AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }). + AddPasswordField("Key", key, 28, '*', func(text string) { key = text }). + AddButton("SAVE", func() { + if name == "" { + a.showError("Name is required") + return + } + if existing == nil { + for _, u := range a.cfg.Provider.Users { + if u.Scheme == schemeName && u.Name == name { + a.showError(fmt.Sprintf("User name %q already exists for this scheme", name)) + return + } + } + } + a.hideModal("user-form") + onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key}) + }). + AddButton("CANCEL", func() { + a.hideModal("user-form") + }) + + form.SetBorder(true). + SetTitle(" [::b]" + title + " "). + SetTitleColor(tcell.NewHexColor(0x39ff14)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) + form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) + form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) + form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) + form.SetLabelColor(tcell.NewHexColor(0xe0e0e0)) + form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff)) + form.SetButtonTextColor(tcell.NewHexColor(0xffffff)) + form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + a.hideModal("user-form") + return nil + } + return event + }) + + a.showModal("user-form", centeredForm(form, 4, 13)) +} diff --git a/cmd/picoclaw/dns_noresolv.go b/cmd/picoclaw/dns_noresolv.go new file mode 100644 index 000000000..ba4ae1f4f --- /dev/null +++ b/cmd/picoclaw/dns_noresolv.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "net" + "net/http" + "os" + "strings" + "sync/atomic" + "time" +) + +func init() { + // 仅在 /etc/resolv.conf 不存在时才覆盖(即 Android 环境) + if _, err := os.Stat("/etc/resolv.conf"); err == nil { + return + } + + // 从环境变量获取 DNS server 列表,多个用 ; 隔开 + // 例如: PICOCLAW_DNS_SERVER="8.8.8.8:53;1.1.1.1:53;223.5.5.5:53" + dnsEnv := os.Getenv("PICOCLAW_DNS_SERVER") + if dnsEnv == "" { + dnsEnv = "8.8.8.8:53;1.1.1.1:53" + } + + var dnsServers []string + for _, s := range strings.Split(dnsEnv, ";") { + s = strings.TrimSpace(s) + if s != "" { + // 如果没有带端口号,自动补上 :53 + if _, _, err := net.SplitHostPort(s); err != nil { + s = s + ":53" + } + dnsServers = append(dnsServers, s) + } + } + + // 轮询索引,在多个 DNS 服务器之间轮转 + var idx uint64 + + customResolver := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: 5 * time.Second} + // Round-robin: 依次尝试不同的 DNS 服务器 + server := dnsServers[atomic.AddUint64(&idx, 1)%uint64(len(dnsServers))] + return d.DialContext(ctx, "udp", server) + }, + } + + // 覆盖全局 DefaultResolver + net.DefaultResolver = customResolver + + // 覆盖 http.DefaultTransport 使用自定义 DNS 解析的 DialContext + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + Resolver: customResolver, + } + + if tr, ok := http.DefaultTransport.(*http.Transport); ok { + tr.DialContext = dialer.DialContext + } +} diff --git a/config/config.example.json b/config/config.example.json index c214f26fa..81c9014ec 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -8,7 +8,11 @@ "temperature": 0.7, "max_tool_iterations": 20, "summarize_message_threshold": 20, - "summarize_token_percent": 75 + "summarize_token_percent": 75, + "tool_feedback": { + "enabled": false, + "max_args_length": 300 + } } }, "model_list": [ @@ -80,7 +84,10 @@ "proxy": "", "allow_from": ["YOUR_USER_ID"], "use_markdown_v2": false, - "reasoning_channel_id": "" + "reasoning_channel_id": "", + "streaming": { + "enabled": true + } }, "discord": { "enabled": false, @@ -200,6 +207,8 @@ "wecom_aibot": { "_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.", "enabled": false, + "bot_id": "YOUR_BOT_ID", + "secret": "YOUR_SECRET", "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", @@ -207,6 +216,25 @@ "welcome_message": "Hello! I'm your AI assistant. How can I help you today?", "reasoning_channel_id": "" }, + "pico": { + "enabled": false, + "token": "YOUR_PICO_TOKEN", + "allow_token_query": false, + "allow_origins": [], + "ping_interval": 30, + "read_timeout": 60, + "max_connections": 100, + "allow_from": [] + }, + "pico_client": { + "enabled": false, + "url": "wss://remote-pico-server/pico/ws", + "token": "YOUR_PICO_TOKEN", + "session_id": "", + "ping_interval": 30, + "read_timeout": 60, + "allow_from": [] + }, "irc": { "enabled": false, "server": "irc.libera.chat:6697", diff --git a/docs/ANTIGRAVITY_AUTH.md b/docs/ANTIGRAVITY_AUTH.md index 89261d899..d88d73c8d 100644 --- a/docs/ANTIGRAVITY_AUTH.md +++ b/docs/ANTIGRAVITY_AUTH.md @@ -438,7 +438,7 @@ type ProviderAuthResult = { ### 1. Required Environment/Dependencies -- Go ≥ 1.21 +- Go ≥ 1.25 - PicoClaw codebase (`pkg/providers/` and `pkg/auth/`) - `crypto` and `net/http` standard library packages @@ -584,7 +584,7 @@ Each SSE message (`data: {...}`) is wrapped in a `response` field: ], "agents": { "defaults": { - "model": "gemini-flash" + "model_name": "gemini-flash" } } } @@ -674,7 +674,7 @@ Add a default entry in `pkg/config/defaults.go`: #### 5. Add Auth Support (Optional) -If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/cmd_auth.go`: +If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/internal/auth/helpers.go`: ```go case "your-provider": @@ -736,7 +736,7 @@ export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/m - `pkg/auth/store.go` - Auth credential storage (`~/.picoclaw/auth.json`) - `pkg/providers/factory.go` - Provider factory and protocol routing - `pkg/providers/types.go` - Provider interface definitions - - `cmd/picoclaw/cmd_auth.go` - Auth CLI commands + - `cmd/picoclaw/internal/auth/helpers.go` - Auth CLI commands - **Documentation:** - `docs/ANTIGRAVITY_USAGE.md` - Antigravity usage guide diff --git a/docs/channels/dingtalk/README.fr.md b/docs/channels/dingtalk/README.fr.md new file mode 100644 index 000000000..969346d65 --- /dev/null +++ b/docs/channels/dingtalk/README.fr.md @@ -0,0 +1,35 @@ +> Retour au [README](../../../README.fr.md) + +# DingTalk + +DingTalk est la plateforme de communication d'entreprise d'Alibaba, très populaire dans les milieux professionnels chinois. Elle utilise un SDK de streaming pour maintenir des connexions persistantes. + +## Configuration + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +| Champ | Type | Requis | Description | +| ------------- | ------ | ------ | ---------------------------------------------------------------- | +| enabled | bool | Oui | Activer ou non le canal DingTalk | +| client_id | string | Oui | Client ID de l'application DingTalk | +| client_secret | string | Oui | Client Secret de l'application DingTalk | +| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs | + +## Procédure de configuration + +1. Rendez-vous sur la [plateforme ouverte DingTalk](https://open.dingtalk.com/) +2. Créez une application interne d'entreprise +3. Obtenez le Client ID et le Client Secret depuis les paramètres de l'application +4. Configurez OAuth et les abonnements aux événements (si nécessaire) +5. Renseignez le Client ID et le Client Secret dans le fichier de configuration diff --git a/docs/channels/dingtalk/README.ja.md b/docs/channels/dingtalk/README.ja.md new file mode 100644 index 000000000..d44a87820 --- /dev/null +++ b/docs/channels/dingtalk/README.ja.md @@ -0,0 +1,35 @@ +> [README](../../../README.ja.md) に戻る + +# DingTalk + +DingTalkはアリババの企業向けコミュニケーションプラットフォームで、中国のビジネス環境で広く利用されています。ストリーミング SDK を使用して持続的な接続を維持します。 + +## 設定 + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| ------------- | ------ | ---- | -------------------------------------------- | +| enabled | bool | はい | DingTalk チャンネルを有効にするかどうか | +| client_id | string | はい | DingTalk アプリケーションの Client ID | +| client_secret | string | はい | DingTalk アプリケーションの Client Secret | +| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 | + +## セットアップ手順 + +1. [DingTalk オープンプラットフォーム](https://open.dingtalk.com/) にアクセスする +2. 企業内部アプリケーションを作成する +3. アプリケーション設定から Client ID と Client Secret を取得する +4. OAuth とイベントサブスクリプションを設定する(必要な場合) +5. Client ID と Client Secret を設定ファイルに入力する diff --git a/docs/channels/dingtalk/README.md b/docs/channels/dingtalk/README.md new file mode 100644 index 000000000..a3f23a1e6 --- /dev/null +++ b/docs/channels/dingtalk/README.md @@ -0,0 +1,35 @@ +> Back to [README](../../../README.md) + +# DingTalk + +DingTalk is Alibaba's enterprise communication platform, widely used in Chinese workplaces. It uses a streaming SDK to maintain persistent connections. + +## Configuration + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +| Field | Type | Required | Description | +| ------------- | ------ | -------- | -------------------------------------------------------- | +| enabled | bool | Yes | Whether to enable the DingTalk channel | +| client_id | string | Yes | Client ID of the DingTalk application | +| client_secret | string | Yes | Client Secret of the DingTalk application | +| allow_from | array | No | User ID whitelist; empty means all users are allowed | + +## Setup + +1. Go to the [DingTalk Open Platform](https://open.dingtalk.com/) +2. Create an internal enterprise application +3. Obtain the Client ID and Client Secret from the application settings +4. Configure OAuth and event subscriptions (if needed) +5. Fill in the Client ID and Client Secret in the configuration file diff --git a/docs/channels/dingtalk/README.pt-br.md b/docs/channels/dingtalk/README.pt-br.md new file mode 100644 index 000000000..f9056217f --- /dev/null +++ b/docs/channels/dingtalk/README.pt-br.md @@ -0,0 +1,35 @@ +> Voltar ao [README](../../../README.pt-br.md) + +# DingTalk + +DingTalk é a plataforma de comunicação empresarial da Alibaba, amplamente utilizada no ambiente corporativo chinês. Ela usa um SDK de streaming para manter conexões persistentes. + +## Configuração + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| ------------- | ------ | ----------- | ---------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal DingTalk deve ser habilitado | +| client_id | string | Sim | Client ID do aplicativo DingTalk | +| client_secret | string | Sim | Client Secret do aplicativo DingTalk | +| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos | + +## Configuração passo a passo + +1. Acesse a [Plataforma Aberta DingTalk](https://open.dingtalk.com/) +2. Crie um aplicativo interno corporativo +3. Obtenha o Client ID e o Client Secret nas configurações do aplicativo +4. Configure OAuth e assinaturas de eventos (se necessário) +5. Preencha o Client ID e o Client Secret no arquivo de configuração diff --git a/docs/channels/dingtalk/README.vi.md b/docs/channels/dingtalk/README.vi.md new file mode 100644 index 000000000..8c060a382 --- /dev/null +++ b/docs/channels/dingtalk/README.vi.md @@ -0,0 +1,35 @@ +> Quay lại [README](../../../README.vi.md) + +# DingTalk + +DingTalk là nền tảng giao tiếp doanh nghiệp của Alibaba, được sử dụng rộng rãi trong môi trường làm việc tại Trung Quốc. Nền tảng này sử dụng SDK streaming để duy trì kết nối liên tục. + +## Cấu hình + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| ------------- | ------ | -------- | ---------------------------------------------------------------- | +| enabled | bool | Có | Có bật kênh DingTalk hay không | +| client_id | string | Có | Client ID của ứng dụng DingTalk | +| client_secret | string | Có | Client Secret của ứng dụng DingTalk | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả | + +## Quy trình thiết lập + +1. Truy cập [Nền tảng mở DingTalk](https://open.dingtalk.com/) +2. Tạo một ứng dụng nội bộ doanh nghiệp +3. Lấy Client ID và Client Secret từ cài đặt ứng dụng +4. Cấu hình OAuth và đăng ký sự kiện (nếu cần) +5. Điền Client ID và Client Secret vào file cấu hình diff --git a/docs/channels/dingtalk/README.zh.md b/docs/channels/dingtalk/README.zh.md index 1e445d0b0..bdaaa1ee1 100644 --- a/docs/channels/dingtalk/README.zh.md +++ b/docs/channels/dingtalk/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../../README.zh.md) + # 钉钉 钉钉是阿里巴巴的企业通讯平台,在中国职场中广受欢迎。它采用流式 SDK 来维持持久连接。 diff --git a/docs/channels/discord/README.fr.md b/docs/channels/discord/README.fr.md new file mode 100644 index 000000000..61c34abb9 --- /dev/null +++ b/docs/channels/discord/README.fr.md @@ -0,0 +1,39 @@ +> Retour au [README](../../../README.fr.md) + +# Discord + +Discord est une application gratuite de chat vocal, vidéo et textuel conçue pour les communautés. PicoClaw se connecte aux serveurs Discord via l'API Bot Discord, avec prise en charge de la réception et de l'envoi de messages. + +## Configuration + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "group_trigger": { + "mention_only": false + } + } + } +} +``` + +| Champ | Type | Requis | Description | +| ------------- | ------ | ------ | --------------------------------------------------------------------------- | +| enabled | bool | Oui | Activer ou non le canal Discord | +| token | string | Oui | Token du bot Discord | +| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs | +| group_trigger | object | Non | Paramètres de déclenchement de groupe (exemple : { "mention_only": false }) | + +## Configuration initiale + +1. Accéder au [Portail des développeurs Discord](https://discord.com/developers/applications) et créer une nouvelle application +2. Activer les Intents : + - Message Content Intent + - Server Members Intent +3. Obtenir le Token du bot +4. Renseigner le Token du bot dans le fichier de configuration +5. Inviter le bot sur le serveur et lui accorder les permissions nécessaires (ex. envoyer des messages, lire l'historique des messages) diff --git a/docs/channels/discord/README.ja.md b/docs/channels/discord/README.ja.md new file mode 100644 index 000000000..ecce30059 --- /dev/null +++ b/docs/channels/discord/README.ja.md @@ -0,0 +1,39 @@ +> [README](../../../README.ja.md) に戻る + +# Discord + +Discord はコミュニティ向けに設計された無料の音声・ビデオ・テキストチャットアプリケーションです。PicoClaw は Discord Bot API を通じて Discord サーバーに接続し、メッセージの受信と送信をサポートします。 + +## 設定 + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "group_trigger": { + "mention_only": false + } + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| ------------- | ------ | ------ | ----------------------------------------------------------------- | +| enabled | bool | はい | Discord チャンネルを有効にするかどうか | +| token | string | はい | Discord ボットトークン | +| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 | +| group_trigger | object | いいえ | グループトリガー設定(例: { "mention_only": false }) | + +## セットアップ手順 + +1. [Discord 開発者ポータル](https://discord.com/developers/applications) にアクセスして新しいアプリケーションを作成する +2. Intents を有効にする: + - Message Content Intent + - Server Members Intent +3. Bot トークンを取得する +4. 設定ファイルに Bot トークンを入力する +5. ボットをサーバーに招待し、必要な権限を付与する(例: メッセージの送信、メッセージ履歴の読み取りなど) diff --git a/docs/channels/discord/README.md b/docs/channels/discord/README.md new file mode 100644 index 000000000..e1ce7ab06 --- /dev/null +++ b/docs/channels/discord/README.md @@ -0,0 +1,39 @@ +> Back to [README](../../../README.md) + +# Discord + +Discord is a free voice, video, and text chat application designed for communities. PicoClaw connects to Discord servers via the Discord Bot API, supporting both receiving and sending messages. + +## Configuration + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "group_trigger": { + "mention_only": false + } + } + } +} +``` + +| Field | Type | Required | Description | +| ------------- | ------ | -------- | --------------------------------------------------------------------------- | +| enabled | bool | Yes | Whether to enable the Discord channel | +| token | string | Yes | Discord Bot Token | +| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | +| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) | + +## Setup + +1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) and create a new application +2. Enable Intents: + - Message Content Intent + - Server Members Intent +3. Obtain the Bot Token +4. Fill in the Bot Token in the configuration file +5. Invite the bot to your server and grant the necessary permissions (e.g. Send Messages, Read Message History) diff --git a/docs/channels/discord/README.pt-br.md b/docs/channels/discord/README.pt-br.md new file mode 100644 index 000000000..c9ed2809b --- /dev/null +++ b/docs/channels/discord/README.pt-br.md @@ -0,0 +1,39 @@ +> Voltar ao [README](../../../README.pt-br.md) + +# Discord + +Discord é um aplicativo gratuito de chat de voz, vídeo e texto projetado para comunidades. O PicoClaw se conecta a servidores Discord via Discord Bot API, com suporte para receber e enviar mensagens. + +## Configuração + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "group_trigger": { + "mention_only": false + } + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| ------------- | ------ | ----------- | --------------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal Discord deve ser habilitado | +| token | string | Sim | Token do Bot Discord | +| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários | +| group_trigger | object | Não | Configurações de gatilho de grupo (exemplo: { "mention_only": false }) | + +## Configuração inicial + +1. Acesse o [Portal de Desenvolvedores do Discord](https://discord.com/developers/applications) e crie uma nova aplicação +2. Habilite os Intents: + - Message Content Intent + - Server Members Intent +3. Obtenha o Token do Bot +4. Preencha o Token do Bot no arquivo de configuração +5. Convide o bot para o servidor e conceda as permissões necessárias (ex. enviar mensagens, ler histórico de mensagens) diff --git a/docs/channels/discord/README.vi.md b/docs/channels/discord/README.vi.md new file mode 100644 index 000000000..7073b04f1 --- /dev/null +++ b/docs/channels/discord/README.vi.md @@ -0,0 +1,39 @@ +> Quay lại [README](../../../README.vi.md) + +# Discord + +Discord là ứng dụng chat thoại, video và văn bản miễn phí được thiết kế cho cộng đồng. PicoClaw kết nối với máy chủ Discord qua Discord Bot API, hỗ trợ nhận và gửi tin nhắn. + +## Cấu hình + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "group_trigger": { + "mention_only": false + } + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| ------------- | ------ | -------- | --------------------------------------------------------------------------- | +| enabled | bool | Có | Có bật kênh Discord hay không | +| token | string | Có | Token Bot Discord | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống nghĩa là cho phép tất cả | +| group_trigger | object | Không | Cài đặt kích hoạt nhóm (ví dụ: { "mention_only": false }) | + +## Hướng dẫn thiết lập + +1. Truy cập [Discord Developer Portal](https://discord.com/developers/applications) và tạo ứng dụng mới +2. Bật các Intents: + - Message Content Intent + - Server Members Intent +3. Lấy Bot Token +4. Điền Bot Token vào file cấu hình +5. Mời bot vào máy chủ và cấp các quyền cần thiết (ví dụ: gửi tin nhắn, đọc lịch sử tin nhắn) diff --git a/docs/channels/discord/README.zh.md b/docs/channels/discord/README.zh.md index 6d3c502cf..673af4854 100644 --- a/docs/channels/discord/README.zh.md +++ b/docs/channels/discord/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../../README.zh.md) + # Discord Discord 是一个专为社区设计的免费语音、视频和文本聊天应用。PicoClaw 通过 Discord Bot API 连接到 Discord 服务器,支持接收和发送消息。 diff --git a/docs/channels/feishu/README.fr.md b/docs/channels/feishu/README.fr.md new file mode 100644 index 000000000..f1ff26480 --- /dev/null +++ b/docs/channels/feishu/README.fr.md @@ -0,0 +1,52 @@ +> Retour au [README](../../../README.fr.md) + +# Feishu + +Feishu (nom international : Lark) est une plateforme de collaboration d'entreprise de ByteDance. Elle prend en charge les marchés chinois et mondiaux via des connexions WebSocket pilotées par événements. + +## Configuration + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + } + } +} +``` + +| Champ | Type | Requis | Description | +| --------------------- | ------ | ------ | --------------------------------------------------------------------------- | +| enabled | bool | Oui | Activer ou non le canal Feishu | +| app_id | string | Oui | App ID de l'application Feishu (commence par `cli_`) | +| app_secret | string | Oui | App Secret de l'application Feishu | +| encrypt_key | string | Non | Clé de chiffrement pour les callbacks d'événements | +| verification_token | string | Non | Token utilisé pour la vérification des événements Webhook | +| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs | +| random_reaction_emoji | array | Non | Liste d'emojis de réaction aléatoires ; vide utilise le "Pin" par défaut | + +## Configuration initiale + +1. Accéder à la [plateforme ouverte Feishu](https://open.feishu.cn/) et créer une application +2. Activer la capacité **Bot** dans les paramètres de l'application +3. Créer une version et publier l'application (la configuration prend effet après la publication) +4. Obtenir l'**App ID** (commence par `cli_`) et l'**App Secret** +5. Renseigner l'App ID et l'App Secret dans le fichier de configuration PicoClaw +6. Exécuter `picoclaw gateway` pour démarrer le service +7. Rechercher le nom du bot dans Feishu et commencer une conversation + +> PicoClaw se connecte à Feishu en mode WebSocket/SDK — aucune adresse de callback publique ni URL Webhook n'est requise. +> +> `encrypt_key` et `verification_token` sont optionnels ; l'activation du chiffrement des événements est recommandée pour les environnements de production. +> +> Pour les références d'emojis personnalisés, voir : [Liste des emojis Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce) + +## Limitations de plateforme + +> ⚠️ **Le canal Feishu ne prend pas en charge les appareils 32 bits.** Le SDK Feishu ne fournit que des builds 64 bits. Les architectures 32 bits (armv6, armv7, mipsle, etc.) ne peuvent pas utiliser le canal Feishu. Pour la messagerie sur des appareils 32 bits, utilisez Telegram, Discord ou OneBot. diff --git a/docs/channels/feishu/README.ja.md b/docs/channels/feishu/README.ja.md new file mode 100644 index 000000000..4bb75a734 --- /dev/null +++ b/docs/channels/feishu/README.ja.md @@ -0,0 +1,52 @@ +> [README](../../../README.ja.md) に戻る + +# 飛書(Feishu) + +飛書(国際名:Lark)は ByteDance が提供するエンタープライズコラボレーションプラットフォームです。イベント駆動型の WebSocket 接続を通じて、中国および世界市場の両方をサポートします。 + +## 設定 + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| --------------------- | ------ | ------ | ----------------------------------------------------------------- | +| enabled | bool | はい | 飛書チャンネルを有効にするかどうか | +| app_id | string | はい | 飛書アプリケーションの App ID(`cli_` で始まる) | +| app_secret | string | はい | 飛書アプリケーションの App Secret | +| encrypt_key | string | いいえ | イベントコールバックの暗号化キー | +| verification_token | string | いいえ | Webhook イベント検証に使用するトークン | +| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 | +| random_reaction_emoji | array | いいえ | ランダムに追加する絵文字のリスト。空の場合はデフォルトの "Pin" を使用 | + +## セットアップ手順 + +1. [飛書オープンプラットフォーム](https://open.feishu.cn/) にアクセスしてアプリケーションを作成する +2. アプリケーション設定で**ボット**機能を有効にする +3. バージョンを作成してアプリケーションを公開する(公開後に設定が有効になる) +4. **App ID**(`cli_` で始まる)と **App Secret** を取得する +5. PicoClaw 設定ファイルに App ID と App Secret を入力する +6. `picoclaw gateway` を実行してサービスを起動する +7. 飛書でボット名を検索して会話を始める + +> PicoClaw は WebSocket/SDK モードで飛書に接続するため、公開コールバックアドレスや Webhook URL の設定は不要です。 +> +> `encrypt_key` と `verification_token` はオプションですが、本番環境ではイベント暗号化を有効にすることを推奨します。 +> +> カスタム絵文字の参考:[飛書絵文字リスト](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce) + +## プラットフォーム制限 + +> ⚠️ **飛書チャネルは 32 ビットデバイスをサポートしていません。** 飛書 SDK は 64 ビットビルドのみ提供しています。armv6 / armv7 / mipsle などの 32 ビットアーキテクチャでは飛書チャネルを使用できません。32 ビットデバイスでのメッセージングには、Telegram、Discord、または OneBot をご利用ください。 diff --git a/docs/channels/feishu/README.md b/docs/channels/feishu/README.md new file mode 100644 index 000000000..2aeaa31cb --- /dev/null +++ b/docs/channels/feishu/README.md @@ -0,0 +1,52 @@ +> Back to [README](../../../README.md) + +# Feishu + +Feishu (international name: Lark) is an enterprise collaboration platform by ByteDance. It supports both Chinese and global markets through event-driven WebSocket connections. + +## Configuration + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + } + } +} +``` + +| Field | Type | Required | Description | +| --------------------- | ------ | -------- | ------------------------------------------------------------------ | +| enabled | bool | Yes | Whether to enable the Feishu channel | +| app_id | string | Yes | App ID of the Feishu application (starts with `cli_`) | +| app_secret | string | Yes | App Secret of the Feishu application | +| encrypt_key | string | No | Encryption key for event callbacks | +| verification_token | string | No | Token used for Webhook event verification | +| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | +| random_reaction_emoji | array | No | List of random reaction emojis; empty uses the default "Pin" | + +## Setup + +1. Go to the [Feishu Open Platform](https://open.feishu.cn/) and create an application +2. Enable the **Bot** capability in the application settings +3. Create a version and publish the application (configuration takes effect only after publishing) +4. Obtain the **App ID** (starts with `cli_`) and **App Secret** +5. Fill in the App ID and App Secret in the PicoClaw configuration file +6. Run `picoclaw gateway` to start the service +7. Search for the bot name in Feishu and start a conversation + +> PicoClaw connects to Feishu using WebSocket/SDK mode — no public callback address or Webhook URL is required. +> +> `encrypt_key` and `verification_token` are optional; enabling event encryption is recommended for production environments. +> +> For custom emoji references, see: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce) + +## Platform Limitations + +> ⚠️ **Feishu channel does not support 32-bit devices.** The Feishu SDK only provides 64-bit builds. Devices running armv6, armv7, mipsle, or other 32-bit architectures cannot use the Feishu channel. For messaging on 32-bit devices, use Telegram, Discord, or OneBot instead. diff --git a/docs/channels/feishu/README.pt-br.md b/docs/channels/feishu/README.pt-br.md new file mode 100644 index 000000000..5b5fcaf68 --- /dev/null +++ b/docs/channels/feishu/README.pt-br.md @@ -0,0 +1,52 @@ +> Voltar ao [README](../../../README.pt-br.md) + +# Feishu + +Feishu (nome internacional: Lark) é uma plataforma de colaboração empresarial da ByteDance. Suporta os mercados chinês e global por meio de conexões WebSocket orientadas a eventos. + +## Configuração + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| --------------------- | ------ | ----------- | -------------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal Feishu deve ser habilitado | +| app_id | string | Sim | App ID da aplicação Feishu (começa com `cli_`) | +| app_secret | string | Sim | App Secret da aplicação Feishu | +| encrypt_key | string | Não | Chave de criptografia para callbacks de eventos | +| verification_token | string | Não | Token usado para verificação de eventos Webhook | +| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários | +| random_reaction_emoji | array | Não | Lista de emojis de reação aleatórios; vazio usa o "Pin" padrão | + +## Configuração inicial + +1. Acesse a [Plataforma Aberta Feishu](https://open.feishu.cn/) e crie uma aplicação +2. Habilite a capacidade de **Bot** nas configurações da aplicação +3. Crie uma versão e publique a aplicação (a configuração entra em vigor após a publicação) +4. Obtenha o **App ID** (começa com `cli_`) e o **App Secret** +5. Preencha o App ID e o App Secret no arquivo de configuração do PicoClaw +6. Execute `picoclaw gateway` para iniciar o serviço +7. Pesquise o nome do bot no Feishu e inicie uma conversa + +> O PicoClaw se conecta ao Feishu usando o modo WebSocket/SDK — nenhum endereço de callback público ou URL de Webhook é necessário. +> +> `encrypt_key` e `verification_token` são opcionais; recomenda-se habilitar a criptografia de eventos em ambientes de produção. +> +> Para referências de emojis personalizados, consulte: [Lista de Emojis do Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce) + +## Limitações de Plataforma + +> ⚠️ **O canal Feishu não suporta dispositivos 32 bits.** O SDK do Feishu fornece apenas builds 64 bits. Arquiteturas 32 bits (armv6, armv7, mipsle, etc.) não podem usar o canal Feishu. Para mensagens em dispositivos 32 bits, use Telegram, Discord ou OneBot. diff --git a/docs/channels/feishu/README.vi.md b/docs/channels/feishu/README.vi.md new file mode 100644 index 000000000..e704b7794 --- /dev/null +++ b/docs/channels/feishu/README.vi.md @@ -0,0 +1,52 @@ +> Quay lại [README](../../../README.vi.md) + +# Feishu + +Feishu (tên quốc tế: Lark) là nền tảng cộng tác doanh nghiệp của ByteDance. Hỗ trợ cả thị trường Trung Quốc và toàn cầu thông qua kết nối WebSocket theo hướng sự kiện. + +## Cấu hình + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| --------------------- | ------ | -------- | ------------------------------------------------------------------------ | +| enabled | bool | Có | Có bật kênh Feishu hay không | +| app_id | string | Có | App ID của ứng dụng Feishu (bắt đầu bằng `cli_`) | +| app_secret | string | Có | App Secret của ứng dụng Feishu | +| encrypt_key | string | Không | Khóa mã hóa cho callback sự kiện | +| verification_token | string | Không | Token dùng để xác minh sự kiện Webhook | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống nghĩa là cho phép tất cả | +| random_reaction_emoji | array | Không | Danh sách emoji phản ứng ngẫu nhiên; để trống dùng "Pin" mặc định | + +## Hướng dẫn thiết lập + +1. Truy cập [Nền tảng Mở Feishu](https://open.feishu.cn/) và tạo ứng dụng +2. Bật khả năng **Bot** trong cài đặt ứng dụng +3. Tạo phiên bản và xuất bản ứng dụng (cấu hình có hiệu lực sau khi xuất bản) +4. Lấy **App ID** (bắt đầu bằng `cli_`) và **App Secret** +5. Điền App ID và App Secret vào file cấu hình PicoClaw +6. Chạy `picoclaw gateway` để khởi động dịch vụ +7. Tìm kiếm tên bot trong Feishu và bắt đầu trò chuyện + +> PicoClaw kết nối với Feishu bằng chế độ WebSocket/SDK — không cần cấu hình địa chỉ callback công khai hay Webhook URL. +> +> `encrypt_key` và `verification_token` là tùy chọn; nên bật mã hóa sự kiện trong môi trường sản xuất. +> +> Tham khảo emoji tùy chỉnh: [Danh sách Emoji Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce) + +## Giới hạn nền tảng + +> ⚠️ **Kênh Feishu không hỗ trợ thiết bị 32 bit.** SDK Feishu chỉ cung cấp bản build 64 bit. Các kiến trúc 32 bit (armv6, armv7, mipsle, v.v.) không thể sử dụng kênh Feishu. Để nhắn tin trên thiết bị 32 bit, hãy dùng Telegram, Discord hoặc OneBot. diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md index db7eb56eb..6e2829547 100644 --- a/docs/channels/feishu/README.zh.md +++ b/docs/channels/feishu/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../../README.zh.md) + # 飞书 飞书(国际版名称:Lark)是字节跳动旗下的企业协作平台。它通过事件驱动的 Webhook 同时支持中国和全球市场。 @@ -39,3 +41,7 @@ 4. 设置加密(可选,生产环境建议启用) 5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中 6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)) + +## 平台限制 + +> ⚠️ **飞书通道不支持 32 位设备。** 飞书官方 SDK 仅提供 64 位构建,armv6 / armv7 / mipsle 等 32 位架构无法使用飞书通道。如需在 32 位设备上接入即时通讯,请改用 Telegram、Discord 或 OneBot 等通道。 diff --git a/docs/channels/line/README.fr.md b/docs/channels/line/README.fr.md new file mode 100644 index 000000000..10bdf3e58 --- /dev/null +++ b/docs/channels/line/README.fr.md @@ -0,0 +1,40 @@ +> Retour au [README](../../../README.fr.md) + +# Line + +PicoClaw prend en charge LINE via l'API LINE Messaging avec des callbacks webhook. + +## Configuration + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +| Champ | Type | Requis | Description | +| -------------------- | ------ | ------ | ------------------------------------------------------------------------ | +| enabled | bool | Oui | Activer ou non le canal LINE | +| channel_secret | string | Oui | Channel Secret de l'API LINE Messaging | +| channel_access_token | string | Oui | Channel Access Token de l'API LINE Messaging | +| webhook_path | string | Non | Chemin du webhook (par défaut : /webhook/line) | +| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs | + +## Procédure de configuration + +1. Rendez-vous sur la [LINE Developers Console](https://developers.line.biz/console/) et créez un fournisseur de services ainsi qu'un canal Messaging API +2. Obtenez le Channel Secret et le Channel Access Token +3. Configurez le webhook : + - LINE exige que les webhooks utilisent HTTPS. Vous devez donc déployer un serveur compatible HTTPS ou utiliser un outil de proxy inverse comme ngrok pour exposer votre serveur local sur Internet + - PicoClaw utilise un serveur HTTP Gateway partagé pour recevoir les callbacks webhook de tous les canaux, écoutant par défaut sur 127.0.0.1:18790 + - Définissez l'URL du webhook sur `https://your-domain.com/webhook/line`, puis configurez un proxy inverse de votre domaine externe vers le Gateway local (port par défaut 18790) + - Activez le webhook et vérifiez l'URL +4. Renseignez le Channel Secret et le Channel Access Token dans le fichier de configuration diff --git a/docs/channels/line/README.ja.md b/docs/channels/line/README.ja.md new file mode 100644 index 000000000..0e559093a --- /dev/null +++ b/docs/channels/line/README.ja.md @@ -0,0 +1,40 @@ +> [README](../../../README.ja.md) に戻る + +# Line + +PicoClaw は LINE Messaging API と Webhook コールバックを通じて LINE をサポートします。 + +## 設定 + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| -------------------- | ------ | ------ | ------------------------------------------------------------------ | +| enabled | bool | はい | LINE チャンネルを有効にするかどうか | +| channel_secret | string | はい | LINE Messaging API の Channel Secret | +| channel_access_token | string | はい | LINE Messaging API の Channel Access Token | +| webhook_path | string | いいえ | Webhook のパス(デフォルト: /webhook/line) | +| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 | + +## セットアップ手順 + +1. [LINE Developers Console](https://developers.line.biz/console/) にアクセスし、サービスプロバイダーと Messaging API チャンネルを作成する +2. Channel Secret と Channel Access Token を取得する +3. Webhook を設定する: + - LINE は Webhook に HTTPS が必要なため、HTTPS 対応サーバーをデプロイするか、ngrok などのリバースプロキシツールを使用してローカルサーバーをインターネットに公開する必要があります + - PicoClaw は共有の Gateway HTTP サーバーを使用してすべてのチャンネルの Webhook コールバックを受信します。デフォルトのリッスンアドレスは 127.0.0.1:18790 です + - Webhook URL を `https://your-domain.com/webhook/line` に設定し、外部ドメインをローカルの Gateway(デフォルトポート 18790)にリバースプロキシする + - Webhook を有効にして URL を検証する +4. Channel Secret と Channel Access Token を設定ファイルに入力する diff --git a/docs/channels/line/README.md b/docs/channels/line/README.md new file mode 100644 index 000000000..1aad18eee --- /dev/null +++ b/docs/channels/line/README.md @@ -0,0 +1,40 @@ +> Back to [README](../../../README.md) + +# Line + +PicoClaw supports LINE through the LINE Messaging API with webhook callbacks. + +## Configuration + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +| Field | Type | Required | Description | +| -------------------- | ------ | -------- | ------------------------------------------------------------------ | +| enabled | bool | Yes | Whether to enable the LINE channel | +| channel_secret | string | Yes | Channel Secret for the LINE Messaging API | +| channel_access_token | string | Yes | Channel Access Token for the LINE Messaging API | +| webhook_path | string | No | Webhook path (default: /webhook/line) | +| allow_from | array | No | User ID whitelist; empty means all users are allowed | + +## Setup + +1. Go to the [LINE Developers Console](https://developers.line.biz/console/) and create a provider and a Messaging API channel +2. Obtain the Channel Secret and Channel Access Token +3. Configure the webhook: + - LINE requires webhooks to use HTTPS, so you need to deploy a server with HTTPS support, or use a reverse proxy tool like ngrok to expose your local server to the internet + - PicoClaw uses a shared Gateway HTTP server to receive webhook callbacks for all channels, listening on 127.0.0.1:18790 by default + - Set the Webhook URL to `https://your-domain.com/webhook/line`, then reverse-proxy your external domain to the local Gateway (default port 18790) + - Enable the webhook and verify the URL +4. Fill in the Channel Secret and Channel Access Token in the configuration file diff --git a/docs/channels/line/README.pt-br.md b/docs/channels/line/README.pt-br.md new file mode 100644 index 000000000..b3334461f --- /dev/null +++ b/docs/channels/line/README.pt-br.md @@ -0,0 +1,40 @@ +> Voltar ao [README](../../../README.pt-br.md) + +# Line + +O PicoClaw suporta o LINE por meio da LINE Messaging API com callbacks de webhook. + +## Configuração + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| -------------------- | ------ | ----------- | ---------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal LINE deve ser habilitado | +| channel_secret | string | Sim | Channel Secret da LINE Messaging API | +| channel_access_token | string | Sim | Channel Access Token da LINE Messaging API | +| webhook_path | string | Não | Caminho do webhook (padrão: /webhook/line) | +| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos | + +## Configuração passo a passo + +1. Acesse o [LINE Developers Console](https://developers.line.biz/console/) e crie um provedor de serviços e um canal Messaging API +2. Obtenha o Channel Secret e o Channel Access Token +3. Configure o webhook: + - O LINE exige que os webhooks usem HTTPS, portanto é necessário implantar um servidor com suporte a HTTPS ou usar uma ferramenta de proxy reverso como o ngrok para expor seu servidor local à internet + - O PicoClaw usa um servidor HTTP Gateway compartilhado para receber callbacks de webhook de todos os canais, escutando em 127.0.0.1:18790 por padrão + - Defina a URL do webhook como `https://your-domain.com/webhook/line` e configure um proxy reverso do seu domínio externo para o Gateway local (porta padrão 18790) + - Ative o webhook e verifique a URL +4. Preencha o Channel Secret e o Channel Access Token no arquivo de configuração diff --git a/docs/channels/line/README.vi.md b/docs/channels/line/README.vi.md new file mode 100644 index 000000000..3e5511a84 --- /dev/null +++ b/docs/channels/line/README.vi.md @@ -0,0 +1,40 @@ +> Quay lại [README](../../../README.vi.md) + +# Line + +PicoClaw hỗ trợ LINE thông qua LINE Messaging API kết hợp với webhook callback. + +## Cấu hình + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| -------------------- | ------ | -------- | ---------------------------------------------------------------------- | +| enabled | bool | Có | Có bật kênh LINE hay không | +| channel_secret | string | Có | Channel Secret của LINE Messaging API | +| channel_access_token | string | Có | Channel Access Token của LINE Messaging API | +| webhook_path | string | Không | Đường dẫn webhook (mặc định: /webhook/line) | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả | + +## Quy trình thiết lập + +1. Truy cập [LINE Developers Console](https://developers.line.biz/console/) và tạo một nhà cung cấp dịch vụ cùng một kênh Messaging API +2. Lấy Channel Secret và Channel Access Token +3. Cấu hình webhook: + - LINE yêu cầu webhook phải sử dụng HTTPS, vì vậy bạn cần triển khai máy chủ hỗ trợ HTTPS hoặc dùng công cụ reverse proxy như ngrok để expose máy chủ cục bộ ra internet + - PicoClaw sử dụng máy chủ HTTP Gateway dùng chung để nhận webhook callback cho tất cả các kênh, mặc định lắng nghe tại 127.0.0.1:18790 + - Đặt Webhook URL thành `https://your-domain.com/webhook/line`, sau đó reverse proxy tên miền bên ngoài về Gateway cục bộ (cổng mặc định 18790) + - Bật webhook và xác minh URL +4. Điền Channel Secret và Channel Access Token vào file cấu hình diff --git a/docs/channels/line/README.zh.md b/docs/channels/line/README.zh.md index a36f622c2..0f7dd0cd8 100644 --- a/docs/channels/line/README.zh.md +++ b/docs/channels/line/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../../README.zh.md) + # Line PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的支持。 diff --git a/docs/channels/maixcam/README.fr.md b/docs/channels/maixcam/README.fr.md new file mode 100644 index 000000000..8fddb203a --- /dev/null +++ b/docs/channels/maixcam/README.fr.md @@ -0,0 +1,35 @@ +> Retour au [README](../../../README.fr.md) + +# MaixCam + +MaixCam est un canal dédié à la connexion aux caméras AI Sipeed MaixCAM et MaixCAM2. Il utilise des sockets TCP pour une communication bidirectionnelle et prend en charge les scénarios de déploiement d'IA en périphérie. + +## Configuration + +```json +{ + "channels": { + "maixcam": { + "enabled": true, + "host": "0.0.0.0", + "port": 18790, + "allow_from": [] + } + } +} +``` + +| Champ | Type | Requis | Description | +| ---------- | ------ | ------ | --------------------------------------------------------------------------- | +| enabled | bool | Oui | Activer ou non le canal MaixCam | +| host | string | Oui | Adresse d'écoute du serveur TCP | +| port | int | Oui | Port d'écoute du serveur TCP | +| allow_from | array | Non | Liste blanche d'identifiants d'appareils ; vide signifie tous les appareils | + +## Cas d'utilisation + +Le canal MaixCam permet à PicoClaw de fonctionner comme backend IA pour les appareils en périphérie : + +- **Surveillance intelligente** : MaixCAM envoie des images ; PicoClaw les analyse via des modèles de vision +- **Contrôle IoT** : Les appareils envoient des données de capteurs ; PicoClaw coordonne les réponses +- **IA hors ligne** : Déployer PicoClaw sur un réseau local pour une inférence à faible latence diff --git a/docs/channels/maixcam/README.ja.md b/docs/channels/maixcam/README.ja.md new file mode 100644 index 000000000..0a5f27baa --- /dev/null +++ b/docs/channels/maixcam/README.ja.md @@ -0,0 +1,35 @@ +> [README](../../../README.ja.md) に戻る + +# MaixCam + +MaixCam は、Sipeed MaixCAM および MaixCAM2 AI カメラデバイスへの接続専用チャンネルです。TCP ソケットを使用した双方向通信を実装し、エッジ AI デプロイメントシナリオをサポートします。 + +## 設定 + +```json +{ + "channels": { + "maixcam": { + "enabled": true, + "host": "0.0.0.0", + "port": 18790, + "allow_from": [] + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| ---------- | ------ | ------ | ------------------------------------------------------------- | +| enabled | bool | はい | MaixCam チャンネルを有効にするかどうか | +| host | string | はい | TCP サーバーのリッスンアドレス | +| port | int | はい | TCP サーバーのリッスンポート | +| allow_from | array | いいえ | 許可するデバイスIDのリスト。空の場合はすべてのデバイスを許可 | + +## ユースケース + +MaixCam チャンネルにより、PicoClaw はエッジデバイスの AI バックエンドとして機能できます: + +- **スマート監視**:MaixCAM が画像フレームを送信し、PicoClaw がビジョンモデルで分析する +- **IoT 制御**:デバイスがセンサーデータを送信し、PicoClaw がレスポンスを調整する +- **オフライン AI**:ローカルネットワークに PicoClaw をデプロイして低遅延推論を実現する diff --git a/docs/channels/maixcam/README.md b/docs/channels/maixcam/README.md new file mode 100644 index 000000000..c22c9236f --- /dev/null +++ b/docs/channels/maixcam/README.md @@ -0,0 +1,35 @@ +> Back to [README](../../../README.md) + +# MaixCam + +MaixCam is a dedicated channel for connecting to Sipeed MaixCAM and MaixCAM2 AI camera devices. It uses TCP sockets for bidirectional communication and supports edge AI deployment scenarios. + +## Configuration + +```json +{ + "channels": { + "maixcam": { + "enabled": true, + "host": "0.0.0.0", + "port": 18790, + "allow_from": [] + } + } +} +``` + +| Field | Type | Required | Description | +| ---------- | ------ | -------- | ---------------------------------------------------------------- | +| enabled | bool | Yes | Whether to enable the MaixCam channel | +| host | string | Yes | TCP server listening address | +| port | int | Yes | TCP server listening port | +| allow_from | array | No | Allowlist of device IDs; empty means all devices are allowed | + +## Use Cases + +The MaixCam channel enables PicoClaw to act as an AI backend for edge devices: + +- **Smart Surveillance**: MaixCAM sends image frames; PicoClaw analyzes them using vision models +- **IoT Control**: Devices send sensor data; PicoClaw coordinates responses +- **Offline AI**: Deploy PicoClaw on a local network for low-latency inference diff --git a/docs/channels/maixcam/README.pt-br.md b/docs/channels/maixcam/README.pt-br.md new file mode 100644 index 000000000..81a1f3f00 --- /dev/null +++ b/docs/channels/maixcam/README.pt-br.md @@ -0,0 +1,35 @@ +> Voltar ao [README](../../../README.pt-br.md) + +# MaixCam + +MaixCam é um canal dedicado para conectar dispositivos de câmera AI Sipeed MaixCAM e MaixCAM2. Utiliza sockets TCP para comunicação bidirecional e suporta cenários de implantação de IA na borda. + +## Configuração + +```json +{ + "channels": { + "maixcam": { + "enabled": true, + "host": "0.0.0.0", + "port": 18790, + "allow_from": [] + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| ---------- | ------ | ----------- | -------------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal MaixCam deve ser habilitado | +| host | string | Sim | Endereço de escuta do servidor TCP | +| port | int | Sim | Porta de escuta do servidor TCP | +| allow_from | array | Não | Lista de IDs de dispositivos permitidos; vazio significa todos os dispositivos | + +## Casos de uso + +O canal MaixCam permite que o PicoClaw atue como backend de IA para dispositivos de borda: + +- **Vigilância inteligente**: MaixCAM envia quadros de imagem; PicoClaw os analisa usando modelos de visão +- **Controle IoT**: Dispositivos enviam dados de sensores; PicoClaw coordena as respostas +- **IA offline**: Implante o PicoClaw em uma rede local para inferência de baixa latência diff --git a/docs/channels/maixcam/README.vi.md b/docs/channels/maixcam/README.vi.md new file mode 100644 index 000000000..8955bae86 --- /dev/null +++ b/docs/channels/maixcam/README.vi.md @@ -0,0 +1,35 @@ +> Quay lại [README](../../../README.vi.md) + +# MaixCam + +MaixCam là kênh chuyên dụng để kết nối với các thiết bị camera AI Sipeed MaixCAM và MaixCAM2. Sử dụng TCP socket để giao tiếp hai chiều và hỗ trợ các kịch bản triển khai AI tại biên. + +## Cấu hình + +```json +{ + "channels": { + "maixcam": { + "enabled": true, + "host": "0.0.0.0", + "port": 18790, + "allow_from": [] + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| ---------- | ------ | -------- | ------------------------------------------------------------------------ | +| enabled | bool | Có | Có bật kênh MaixCam hay không | +| host | string | Có | Địa chỉ lắng nghe của máy chủ TCP | +| port | int | Có | Cổng lắng nghe của máy chủ TCP | +| allow_from | array | Không | Danh sách trắng ID thiết bị; để trống nghĩa là cho phép tất cả thiết bị | + +## Trường hợp sử dụng + +Kênh MaixCam cho phép PicoClaw hoạt động như backend AI cho các thiết bị biên: + +- **Giám sát thông minh**: MaixCAM gửi khung hình ảnh; PicoClaw phân tích bằng mô hình thị giác +- **Điều khiển IoT**: Thiết bị gửi dữ liệu cảm biến; PicoClaw điều phối phản hồi +- **AI ngoại tuyến**: Triển khai PicoClaw trên mạng nội bộ để suy luận độ trễ thấp diff --git a/docs/channels/maixcam/README.zh.md b/docs/channels/maixcam/README.zh.md index 8d53d4bef..b0d58e733 100644 --- a/docs/channels/maixcam/README.zh.md +++ b/docs/channels/maixcam/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../../README.zh.md) + # MaixCam MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的通道。它采用 TCP 套接字实现双向通信,支持边缘 AI 部署场景。 @@ -9,18 +11,20 @@ MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的 "channels": { "maixcam": { "enabled": true, - "server_address": "0.0.0.0:8899", + "host": "0.0.0.0", + "port": 18790, "allow_from": [] } } } ``` -| 字段 | 类型 | 必填 | 描述 | -| -------------- | ------ | ---- | -------------------------------- | -| enabled | bool | 是 | 是否启用 MaixCam 频道 | -| server_address | string | 是 | TCP 服务器监听地址和端口 | -| allow_from | array | 否 | 设备ID白名单,空表示允许所有设备 | +| 字段 | 类型 | 必填 | 描述 | +| ---------- | ------ | ---- | -------------------------------- | +| enabled | bool | 是 | 是否启用 MaixCam 频道 | +| host | string | 是 | TCP 服务器监听地址 | +| port | int | 是 | TCP 服务器监听端口 | +| allow_from | array | 否 | 设备ID白名单,空表示允许所有设备 | ## 使用场景 diff --git a/docs/channels/matrix/README.zh.md b/docs/channels/matrix/README.zh.md index efbc13093..1f9e5bbe2 100644 --- a/docs/channels/matrix/README.zh.md +++ b/docs/channels/matrix/README.zh.md @@ -42,6 +42,7 @@ | group_trigger | object | 否 | 群聊触发策略(支持 `mention_only` / `prefixes`) | | placeholder | object | 否 | 占位消息配置 | | reasoning_channel_id | string | 否 | 思维链输出目标通道 | +| message_format | string | 否 | 消息格式:`richtext`(富文本)或 `plain`(纯文本) | ## 3. 当前支持 diff --git a/docs/channels/onebot/README.fr.md b/docs/channels/onebot/README.fr.md new file mode 100644 index 000000000..7c9ffe1d3 --- /dev/null +++ b/docs/channels/onebot/README.fr.md @@ -0,0 +1,33 @@ +> Retour au [README](../../../README.fr.md) + +# OneBot + +OneBot est un standard de protocole ouvert pour les bots QQ, fournissant une interface unifiée pour diverses implémentations de bots QQ (par exemple go-cqhttp, Mirai). Il utilise WebSocket pour la communication. + +## Configuration + +```json +{ + "channels": { + "onebot": { + "enabled": true, + "ws_url": "ws://localhost:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| Champ | Type | Requis | Description | +| ------------ | ------ | ------ | -------------------------------------------------------------------- | +| enabled | bool | Oui | Activer ou non le canal OneBot | +| ws_url | string | Oui | URL WebSocket du serveur OneBot | +| access_token | string | Non | Jeton d'accès pour la connexion au serveur OneBot | +| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs | + +## Procédure de configuration + +1. Déployez une implémentation compatible OneBot (par exemple napcat) +2. Configurez l'implémentation OneBot pour activer le service WebSocket et définir un jeton d'accès (si nécessaire) +3. Renseignez l'URL WebSocket et le jeton d'accès dans le fichier de configuration diff --git a/docs/channels/onebot/README.ja.md b/docs/channels/onebot/README.ja.md new file mode 100644 index 000000000..ce628572b --- /dev/null +++ b/docs/channels/onebot/README.ja.md @@ -0,0 +1,33 @@ +> [README](../../../README.ja.md) に戻る + +# OneBot + +OneBot は QQ ボット向けのオープンプロトコル標準で、複数の QQ ボット実装(例: go-cqhttp、Mirai)に統一されたインターフェースを提供します。通信には WebSocket を使用します。 + +## 設定 + +```json +{ + "channels": { + "onebot": { + "enabled": true, + "ws_url": "ws://localhost:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| ------------ | ------ | ------ | ---------------------------------------------------------------- | +| enabled | bool | はい | OneBot チャンネルを有効にするかどうか | +| ws_url | string | はい | OneBot サーバーの WebSocket URL | +| access_token | string | いいえ | OneBot サーバーへの接続に使用するアクセストークン | +| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 | + +## セットアップ手順 + +1. OneBot 互換の実装(例: napcat)をデプロイする +2. OneBot 実装で WebSocket サービスを有効にし、アクセストークンを設定する(必要な場合) +3. WebSocket URL とアクセストークンを設定ファイルに入力する diff --git a/docs/channels/onebot/README.md b/docs/channels/onebot/README.md new file mode 100644 index 000000000..42af39b4e --- /dev/null +++ b/docs/channels/onebot/README.md @@ -0,0 +1,33 @@ +> Back to [README](../../../README.md) + +# OneBot + +OneBot is an open protocol standard for QQ bots, providing a unified interface for various QQ bot implementations (e.g. go-cqhttp, Mirai). It uses WebSocket for communication. + +## Configuration + +```json +{ + "channels": { + "onebot": { + "enabled": true, + "ws_url": "ws://localhost:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| Field | Type | Required | Description | +| ------------ | ------ | -------- | ---------------------------------------------------------------- | +| enabled | bool | Yes | Whether to enable the OneBot channel | +| ws_url | string | Yes | WebSocket URL of the OneBot server | +| access_token | string | No | Access token for connecting to the OneBot server | +| allow_from | array | No | User ID whitelist; empty means all users are allowed | + +## Setup + +1. Deploy a OneBot-compatible implementation (e.g. napcat) +2. Configure the OneBot implementation to enable the WebSocket service and set an access token (if needed) +3. Fill in the WebSocket URL and access token in the configuration file diff --git a/docs/channels/onebot/README.pt-br.md b/docs/channels/onebot/README.pt-br.md new file mode 100644 index 000000000..5323163ee --- /dev/null +++ b/docs/channels/onebot/README.pt-br.md @@ -0,0 +1,33 @@ +> Voltar ao [README](../../../README.pt-br.md) + +# OneBot + +OneBot é um padrão de protocolo aberto para bots QQ, fornecendo uma interface unificada para diversas implementações de bots QQ (ex.: go-cqhttp, Mirai). Utiliza WebSocket para comunicação. + +## Configuração + +```json +{ + "channels": { + "onebot": { + "enabled": true, + "ws_url": "ws://localhost:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| ------------ | ------ | ----------- | -------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal OneBot deve ser habilitado | +| ws_url | string | Sim | URL WebSocket do servidor OneBot | +| access_token | string | Não | Token de acesso para conexão ao servidor OneBot | +| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos | + +## Configuração passo a passo + +1. Implante uma implementação compatível com OneBot (ex.: napcat) +2. Configure a implementação OneBot para habilitar o serviço WebSocket e definir um token de acesso (se necessário) +3. Preencha a URL WebSocket e o token de acesso no arquivo de configuração diff --git a/docs/channels/onebot/README.vi.md b/docs/channels/onebot/README.vi.md new file mode 100644 index 000000000..a572e7afa --- /dev/null +++ b/docs/channels/onebot/README.vi.md @@ -0,0 +1,33 @@ +> Quay lại [README](../../../README.vi.md) + +# OneBot + +OneBot là tiêu chuẩn giao thức mở dành cho bot QQ, cung cấp giao diện thống nhất cho nhiều triển khai bot QQ khác nhau (ví dụ: go-cqhttp, Mirai). Nó sử dụng WebSocket để giao tiếp. + +## Cấu hình + +```json +{ + "channels": { + "onebot": { + "enabled": true, + "ws_url": "ws://localhost:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| ------------ | ------ | -------- | -------------------------------------------------------------------- | +| enabled | bool | Có | Có bật kênh OneBot hay không | +| ws_url | string | Có | URL WebSocket của máy chủ OneBot | +| access_token | string | Không | Token truy cập để kết nối với máy chủ OneBot | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả | + +## Quy trình thiết lập + +1. Triển khai một bản triển khai tương thích OneBot (ví dụ: napcat) +2. Cấu hình bản triển khai OneBot để bật dịch vụ WebSocket và đặt token truy cập (nếu cần) +3. Điền URL WebSocket và token truy cập vào file cấu hình diff --git a/docs/channels/onebot/README.zh.md b/docs/channels/onebot/README.zh.md index 6195f1c98..8caba0b80 100644 --- a/docs/channels/onebot/README.zh.md +++ b/docs/channels/onebot/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../../README.zh.md) + # OneBot OneBot 是一个面向 QQ 机器人的开放协议标准,为多种 QQ 机器人实现(例如 go-cqhttp、Mirai)提供了统一的接口。它使用 WebSocket 进行通信。 diff --git a/docs/channels/qq/README.fr.md b/docs/channels/qq/README.fr.md new file mode 100644 index 000000000..38de1b751 --- /dev/null +++ b/docs/channels/qq/README.fr.md @@ -0,0 +1,54 @@ +> Retour au [README](../../../README.fr.md) + +# QQ + +PicoClaw prend en charge QQ via l'API Bot officielle de la plateforme ouverte QQ. + +## Configuration + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +| Champ | Type | Requis | Description | +| ---------- | ------ | ------ | --------------------------------------------------------------------------- | +| enabled | bool | Oui | Activer ou non le canal QQ | +| app_id | string | Oui | App ID de l'application bot QQ | +| app_secret | string | Oui | App Secret de l'application bot QQ | +| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs | + +## Configuration initiale + +### Configuration rapide (recommandée) + +La plateforme ouverte QQ propose une entrée de création en un clic : + +1. Ouvrir [QQ Bot Quick Create](https://q.qq.com/qqbot/openclaw/index.html) et se connecter en scannant le QR code +2. Le système crée automatiquement un bot — copier l'**App ID** et l'**App Secret** +3. Renseigner les identifiants dans le fichier de configuration PicoClaw +4. Exécuter `picoclaw gateway` pour démarrer le service +5. Ouvrir QQ et commencer à discuter avec le bot + +> L'App Secret n'est affiché qu'une seule fois — sauvegardez-le immédiatement. Le consulter à nouveau forcera une réinitialisation. +> +> Les bots créés via l'entrée rapide sont réservés à l'usage personnel du créateur et ne prennent pas en charge les discussions de groupe. Pour la prise en charge des groupes, configurez le mode sandbox sur la [plateforme ouverte QQ](https://q.qq.com/). + +### Configuration manuelle + +1. Se connecter à la [plateforme ouverte QQ](https://q.qq.com/) avec son compte QQ et s'inscrire en tant que développeur +2. Créer un bot QQ et personnaliser son avatar et son nom +3. Obtenir l'**App ID** et l'**App Secret** dans les paramètres du bot +4. Renseigner les identifiants dans le fichier de configuration PicoClaw +5. Exécuter `picoclaw gateway` pour démarrer le service +6. Rechercher votre bot dans QQ et commencer à discuter + +> Pendant le développement, il est recommandé d'activer le mode sandbox et d'y ajouter les utilisateurs et groupes de test pour le débogage. diff --git a/docs/channels/qq/README.ja.md b/docs/channels/qq/README.ja.md new file mode 100644 index 000000000..2990f9622 --- /dev/null +++ b/docs/channels/qq/README.ja.md @@ -0,0 +1,54 @@ +> [README](../../../README.ja.md) に戻る + +# QQ + +PicoClaw は QQ オープンプラットフォームの公式 Bot API を通じて QQ をサポートします。 + +## 設定 + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| ---------- | ------ | ------ | ------------------------------------------------------------- | +| enabled | bool | はい | QQ チャンネルを有効にするかどうか | +| app_id | string | はい | QQ ボットアプリケーションの App ID | +| app_secret | string | はい | QQ ボットアプリケーションの App Secret | +| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 | + +## セットアップ手順 + +### クイックセットアップ(推奨) + +QQ オープンプラットフォームにはワンクリック作成エントリーが用意されています: + +1. [QQ ボットクイック作成](https://q.qq.com/qqbot/openclaw/index.html) を開き、QR コードをスキャンしてログインする +2. システムが自動的にボットを作成するので、**App ID** と **App Secret** をコピーする +3. PicoClaw 設定ファイルに認証情報を入力する +4. `picoclaw gateway` を実行してサービスを起動する +5. QQ を開いてボットとの会話を始める + +> App Secret は一度しか表示されません。すぐに保存してください。再度表示しようとすると強制的にリセットされます。 +> +> クイックエントリーで作成したボットは作成者本人のみが使用でき、グループチャットには対応していません。グループチャット機能が必要な場合は、[QQ オープンプラットフォーム](https://q.qq.com/) でサンドボックスモードを設定してください。 + +### 手動セットアップ + +1. QQ アカウントで [QQ オープンプラットフォーム](https://q.qq.com/) にログインし、開発者アカウントを登録する +2. QQ ボットを作成し、アバターと名前をカスタマイズする +3. ボット設定から **App ID** と **App Secret** を取得する +4. PicoClaw 設定ファイルに認証情報を入力する +5. `picoclaw gateway` を実行してサービスを起動する +6. QQ でボットを検索して会話を始める + +> 開発段階ではサンドボックスモードを有効にし、テストユーザーとグループをサンドボックスに追加してデバッグすることを推奨します。 diff --git a/docs/channels/qq/README.md b/docs/channels/qq/README.md new file mode 100644 index 000000000..35e4a769c --- /dev/null +++ b/docs/channels/qq/README.md @@ -0,0 +1,54 @@ +> Back to [README](../../../README.md) + +# QQ + +PicoClaw provides QQ support via the official Bot API from the QQ Open Platform. + +## Configuration + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +| Field | Type | Required | Description | +| ---------- | ------ | -------- | -------------------------------------------------------- | +| enabled | bool | Yes | Whether to enable the QQ channel | +| app_id | string | Yes | App ID of the QQ bot application | +| app_secret | string | Yes | App Secret of the QQ bot application | +| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | + +## Setup + +### Quick Setup (Recommended) + +The QQ Open Platform provides a one-click creation entry: + +1. Open [QQ Bot Quick Create](https://q.qq.com/qqbot/openclaw/index.html) and log in by scanning the QR code +2. The system automatically creates a bot — copy the **App ID** and **App Secret** +3. Fill in the credentials in the PicoClaw configuration file +4. Run `picoclaw gateway` to start the service +5. Open QQ and start chatting with the bot + +> The App Secret is only shown once — save it immediately. Viewing it again will force a reset. +> +> Bots created via the quick entry are for the creator's personal use only and do not support group chats. For group chat support, configure sandbox mode on the [QQ Open Platform](https://q.qq.com/). + +### Manual Setup + +1. Log in to the [QQ Open Platform](https://q.qq.com/) with your QQ account and register as a developer +2. Create a QQ bot and customize its avatar and name +3. Obtain the **App ID** and **App Secret** from the bot settings +4. Fill in the credentials in the PicoClaw configuration file +5. Run `picoclaw gateway` to start the service +6. Search for your bot in QQ and start chatting + +> During development, it is recommended to enable sandbox mode and add test users and groups to the sandbox for debugging. diff --git a/docs/channels/qq/README.pt-br.md b/docs/channels/qq/README.pt-br.md new file mode 100644 index 000000000..507df7f7e --- /dev/null +++ b/docs/channels/qq/README.pt-br.md @@ -0,0 +1,54 @@ +> Voltar ao [README](../../../README.pt-br.md) + +# QQ + +O PicoClaw oferece suporte ao QQ via API Bot oficial da Plataforma Aberta QQ. + +## Configuração + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| ---------- | ------ | ----------- | -------------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal QQ deve ser habilitado | +| app_id | string | Sim | App ID da aplicação bot QQ | +| app_secret | string | Sim | App Secret da aplicação bot QQ | +| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários | + +## Configuração inicial + +### Configuração rápida (recomendada) + +A Plataforma Aberta QQ oferece uma entrada de criação com um clique: + +1. Abra o [QQ Bot Quick Create](https://q.qq.com/qqbot/openclaw/index.html) e faça login escaneando o QR code +2. O sistema cria o bot automaticamente — copie o **App ID** e o **App Secret** +3. Preencha as credenciais no arquivo de configuração do PicoClaw +4. Execute `picoclaw gateway` para iniciar o serviço +5. Abra o QQ e comece a conversar com o bot + +> O App Secret é exibido apenas uma vez — salve-o imediatamente. Visualizá-lo novamente forçará uma redefinição. +> +> Bots criados pela entrada rápida são apenas para uso pessoal do criador e não suportam chats em grupo. Para suporte a grupos, configure o modo sandbox na [Plataforma Aberta QQ](https://q.qq.com/). + +### Configuração manual + +1. Faça login na [Plataforma Aberta QQ](https://q.qq.com/) com sua conta QQ e registre-se como desenvolvedor +2. Crie um bot QQ e personalize seu avatar e nome +3. Obtenha o **App ID** e o **App Secret** nas configurações do bot +4. Preencha as credenciais no arquivo de configuração do PicoClaw +5. Execute `picoclaw gateway` para iniciar o serviço +6. Pesquise seu bot no QQ e comece a conversar + +> Durante o desenvolvimento, recomenda-se habilitar o modo sandbox e adicionar usuários e grupos de teste ao sandbox para depuração. diff --git a/docs/channels/qq/README.vi.md b/docs/channels/qq/README.vi.md new file mode 100644 index 000000000..1f3eb89da --- /dev/null +++ b/docs/channels/qq/README.vi.md @@ -0,0 +1,54 @@ +> Quay lại [README](../../../README.vi.md) + +# QQ + +PicoClaw hỗ trợ QQ thông qua API Bot chính thức của Nền tảng Mở QQ. + +## Cấu hình + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| ---------- | ------ | -------- | ------------------------------------------------------------------------ | +| enabled | bool | Có | Có bật kênh QQ hay không | +| app_id | string | Có | App ID của ứng dụng bot QQ | +| app_secret | string | Có | App Secret của ứng dụng bot QQ | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống nghĩa là cho phép tất cả | + +## Hướng dẫn thiết lập + +### Thiết lập nhanh (Khuyến nghị) + +Nền tảng Mở QQ cung cấp lối vào tạo bot một chạm: + +1. Mở [QQ Bot Quick Create](https://q.qq.com/qqbot/openclaw/index.html) và đăng nhập bằng cách quét mã QR +2. Hệ thống tự động tạo bot — sao chép **App ID** và **App Secret** +3. Điền thông tin xác thực vào file cấu hình PicoClaw +4. Chạy `picoclaw gateway` để khởi động dịch vụ +5. Mở QQ và bắt đầu trò chuyện với bot + +> App Secret chỉ hiển thị một lần — hãy lưu lại ngay. Xem lại sẽ buộc phải đặt lại. +> +> Bot được tạo qua lối vào nhanh chỉ dành cho người tạo sử dụng cá nhân và chưa hỗ trợ chat nhóm. Để hỗ trợ chat nhóm, hãy cấu hình chế độ sandbox trên [Nền tảng Mở QQ](https://q.qq.com/). + +### Tạo thủ công + +1. Đăng nhập vào [Nền tảng Mở QQ](https://q.qq.com/) bằng tài khoản QQ và đăng ký tài khoản nhà phát triển +2. Tạo bot QQ, tùy chỉnh ảnh đại diện và tên +3. Lấy **App ID** và **App Secret** trong cài đặt bot +4. Điền thông tin xác thực vào file cấu hình PicoClaw +5. Chạy `picoclaw gateway` để khởi động dịch vụ +6. Tìm kiếm bot của bạn trong QQ và bắt đầu trò chuyện + +> Trong giai đoạn phát triển, nên bật chế độ sandbox và thêm người dùng, nhóm thử nghiệm vào sandbox để gỡ lỗi. diff --git a/docs/channels/qq/README.zh.md b/docs/channels/qq/README.zh.md index 6211d2ec4..e7f6d2050 100644 --- a/docs/channels/qq/README.zh.md +++ b/docs/channels/qq/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../../README.zh.md) + # QQ PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。 @@ -28,7 +30,27 @@ PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。 ## 设置流程 -1. 前往 [QQ 开放平台](https://q.qq.com/) 创建一个机器人 -2. 通过仪表盘获取 App ID 和 App Secret -3. 开启机器人沙箱模式, 将用户和群添加到沙箱中 -4. 将 App ID 和 App Secret 填入配置文件中 +### 快捷方式(推荐) + +QQ 开放平台提供了一键创建入口: + +1. 打开 [QQ 机器人快速创建](https://q.qq.com/qqbot/openclaw/index.html),扫码登录 +2. 系统自动创建机器人,复制 **App ID** 和 **App Secret** +3. 将凭证填入 PicoClaw 配置文件 +4. 运行 `picoclaw gateway` 启动服务 +5. 打开 QQ,与机器人开始对话 + +> App Secret 仅显示一次,请立即保存。再次查看将强制重置。 +> +> 通过快捷入口创建的机器人仅供创建人使用,暂不支持群聊。如需群聊功能,请在 [QQ 开放平台](https://q.qq.com/) 配置沙箱模式。 + +### 手动创建 + +1. 使用 QQ 账号登录 [QQ 开放平台](https://q.qq.com/),注册开发者账号 +2. 创建 QQ 机器人,自定义头像和名称 +3. 在机器人设置中获取 **App ID** 和 **App Secret** +4. 将凭证填入 PicoClaw 配置文件 +5. 运行 `picoclaw gateway` 启动服务 +6. 在 QQ 中搜索你的机器人,开始对话 + +> 开发阶段建议开启沙箱模式,将测试用户和群添加到沙箱中进行调试。 diff --git a/docs/channels/slack/README.fr.md b/docs/channels/slack/README.fr.md new file mode 100644 index 000000000..81dcebdec --- /dev/null +++ b/docs/channels/slack/README.fr.md @@ -0,0 +1,35 @@ +> Retour au [README](../../../README.fr.md) + +# Slack + +Slack est l'une des principales plateformes de messagerie instantanée pour les entreprises. PicoClaw utilise le Socket Mode de Slack pour une communication bidirectionnelle en temps réel, sans nécessiter la configuration d'un endpoint webhook public. + +## Configuration + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "xoxb-...", + "app_token": "xapp-...", + "allow_from": [] + } + } +} +``` + +| Champ | Type | Requis | Description | +| ---------- | ------ | ------ | ---------------------------------------------------------------------------- | +| enabled | bool | Oui | Activer ou non le canal Slack | +| bot_token | string | Oui | Bot User OAuth Token du bot Slack (commence par xoxb-) | +| app_token | string | Oui | App Level Token Socket Mode de l'application Slack (commence par xapp-) | +| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs | + +## Procédure de configuration + +1. Rendez-vous sur [Slack API](https://api.slack.com/) et créez une nouvelle application Slack +2. Activez le Socket Mode et obtenez l'App Level Token +3. Ajoutez des Bot Token Scopes (par exemple `chat:write`, `im:history`, etc.) +4. Installez l'application dans votre espace de travail et obtenez le Bot User OAuth Token +5. Renseignez le Bot Token et l'App Token dans le fichier de configuration diff --git a/docs/channels/slack/README.ja.md b/docs/channels/slack/README.ja.md new file mode 100644 index 000000000..c8d268b9c --- /dev/null +++ b/docs/channels/slack/README.ja.md @@ -0,0 +1,35 @@ +> [README](../../../README.ja.md) に戻る + +# Slack + +Slack は世界をリードする企業向けインスタントメッセージングプラットフォームです。PicoClaw は Slack の Socket Mode を使用してリアルタイムの双方向通信を実現しており、公開 Webhook エンドポイントの設定は不要です。 + +## 設定 + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "xoxb-...", + "app_token": "xapp-...", + "allow_from": [] + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| ---------- | ------ | ------ | ------------------------------------------------------------------------ | +| enabled | bool | はい | Slack チャンネルを有効にするかどうか | +| bot_token | string | はい | Slack ボットの Bot User OAuth Token(xoxb- で始まる) | +| app_token | string | はい | Slack アプリの Socket Mode App Level Token(xapp- で始まる) | +| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 | + +## セットアップ手順 + +1. [Slack API](https://api.slack.com/) にアクセスして新しい Slack アプリを作成する +2. Socket Mode を有効にして App Level Token を取得する +3. Bot Token Scopes を追加する(例: `chat:write`、`im:history` など) +4. アプリをワークスペースにインストールして Bot User OAuth Token を取得する +5. Bot Token と App Token を設定ファイルに入力する diff --git a/docs/channels/slack/README.md b/docs/channels/slack/README.md new file mode 100644 index 000000000..9d5aafab9 --- /dev/null +++ b/docs/channels/slack/README.md @@ -0,0 +1,35 @@ +> Back to [README](../../../README.md) + +# Slack + +Slack is a leading enterprise instant messaging platform. PicoClaw uses Slack's Socket Mode for real-time bidirectional communication, with no need to configure a public webhook endpoint. + +## Configuration + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "xoxb-...", + "app_token": "xapp-...", + "allow_from": [] + } + } +} +``` + +| Field | Type | Required | Description | +| ---------- | ------ | -------- | ------------------------------------------------------------------------ | +| enabled | bool | Yes | Whether to enable the Slack channel | +| bot_token | string | Yes | Bot User OAuth Token for the Slack bot (starts with xoxb-) | +| app_token | string | Yes | Socket Mode App Level Token for the Slack app (starts with xapp-) | +| allow_from | array | No | User ID whitelist; empty means all users are allowed | + +## Setup + +1. Go to [Slack API](https://api.slack.com/) and create a new Slack app +2. Enable Socket Mode and obtain the App Level Token +3. Add Bot Token Scopes (e.g. `chat:write`, `im:history`, etc.) +4. Install the app to your workspace and obtain the Bot User OAuth Token +5. Fill in the Bot Token and App Token in the configuration file diff --git a/docs/channels/slack/README.pt-br.md b/docs/channels/slack/README.pt-br.md new file mode 100644 index 000000000..ea8a6c0fc --- /dev/null +++ b/docs/channels/slack/README.pt-br.md @@ -0,0 +1,35 @@ +> Voltar ao [README](../../../README.pt-br.md) + +# Slack + +O Slack é uma das principais plataformas de mensagens instantâneas para empresas. O PicoClaw usa o Socket Mode do Slack para comunicação bidirecional em tempo real, sem necessidade de configurar um endpoint de webhook público. + +## Configuração + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "xoxb-...", + "app_token": "xapp-...", + "allow_from": [] + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| ---------- | ------ | ----------- | ---------------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal Slack deve ser habilitado | +| bot_token | string | Sim | Bot User OAuth Token do bot Slack (começa com xoxb-) | +| app_token | string | Sim | App Level Token do Socket Mode do aplicativo Slack (começa com xapp-) | +| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos | + +## Configuração passo a passo + +1. Acesse o [Slack API](https://api.slack.com/) e crie um novo aplicativo Slack +2. Ative o Socket Mode e obtenha o App Level Token +3. Adicione Bot Token Scopes (ex.: `chat:write`, `im:history`, etc.) +4. Instale o aplicativo no seu workspace e obtenha o Bot User OAuth Token +5. Preencha o Bot Token e o App Token no arquivo de configuração diff --git a/docs/channels/slack/README.vi.md b/docs/channels/slack/README.vi.md new file mode 100644 index 000000000..dae84728c --- /dev/null +++ b/docs/channels/slack/README.vi.md @@ -0,0 +1,35 @@ +> Quay lại [README](../../../README.vi.md) + +# Slack + +Slack là nền tảng nhắn tin tức thì hàng đầu dành cho doanh nghiệp. PicoClaw sử dụng Socket Mode của Slack để giao tiếp hai chiều theo thời gian thực, không cần cấu hình endpoint webhook công khai. + +## Cấu hình + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "xoxb-...", + "app_token": "xapp-...", + "allow_from": [] + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| ---------- | ------ | -------- | ---------------------------------------------------------------------------- | +| enabled | bool | Có | Có bật kênh Slack hay không | +| bot_token | string | Có | Bot User OAuth Token của Slack bot (bắt đầu bằng xoxb-) | +| app_token | string | Có | App Level Token Socket Mode của ứng dụng Slack (bắt đầu bằng xapp-) | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả | + +## Quy trình thiết lập + +1. Truy cập [Slack API](https://api.slack.com/) và tạo một ứng dụng Slack mới +2. Bật Socket Mode và lấy App Level Token +3. Thêm Bot Token Scopes (ví dụ: `chat:write`, `im:history`, v.v.) +4. Cài đặt ứng dụng vào workspace và lấy Bot User OAuth Token +5. Điền Bot Token và App Token vào file cấu hình diff --git a/docs/channels/slack/README.zh.md b/docs/channels/slack/README.zh.md index 58ebcb566..884039162 100644 --- a/docs/channels/slack/README.zh.md +++ b/docs/channels/slack/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../../README.zh.md) + # Slack Slack 是全球领先的企业级即时通讯平台。PicoClaw 采用 Slack 的 Socket Mode 实现实时双向通信,无需配置公开的 Webhook 端点。 diff --git a/docs/channels/telegram/README.fr.md b/docs/channels/telegram/README.fr.md new file mode 100644 index 000000000..d9ab0644f --- /dev/null +++ b/docs/channels/telegram/README.fr.md @@ -0,0 +1,35 @@ +> Retour au [README](../../../README.fr.md) + +# Telegram + +Le canal Telegram utilise le long polling via l'API Bot Telegram pour une communication basée sur les bots. Il prend en charge les messages texte, les pièces jointes multimédias (photos, messages vocaux, audio, documents), la transcription vocale via Groq Whisper et la gestion des commandes intégrée. + +## Configuration + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", + "allow_from": ["123456789"], + "proxy": "" + } + } +} +``` + +| Champ | Type | Requis | Description | +| ---------- | ------ | ------ | ------------------------------------------------------------------------ | +| enabled | bool | Oui | Activer ou non le canal Telegram | +| token | string | Oui | Token de l'API Bot Telegram | +| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs | +| proxy | string | Non | URL du proxy pour se connecter à l'API Telegram (ex. http://127.0.0.1:7890) | + +## Configuration initiale + +1. Rechercher `@BotFather` dans Telegram +2. Envoyer la commande `/newbot` et suivre les instructions pour créer un nouveau bot +3. Obtenir le Token de l'API HTTP +4. Renseigner le Token dans le fichier de configuration +5. (Optionnel) Configurer `allow_from` pour restreindre les identifiants utilisateur autorisés à interagir (les IDs peuvent être obtenus via `@userinfobot`) diff --git a/docs/channels/telegram/README.ja.md b/docs/channels/telegram/README.ja.md new file mode 100644 index 000000000..03c48cb64 --- /dev/null +++ b/docs/channels/telegram/README.ja.md @@ -0,0 +1,35 @@ +> [README](../../../README.ja.md) に戻る + +# Telegram + +Telegram チャンネルは、Telegram Bot API を使用したロングポーリングによるボットベースの通信を実装しています。テキストメッセージ、メディア添付ファイル(写真、音声、オーディオ、ドキュメント)、Groq Whisper による音声文字起こし、および組み込みコマンドハンドラーをサポートしています。 + +## 設定 + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", + "allow_from": ["123456789"], + "proxy": "" + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| ---------- | ------ | ---- | ----------------------------------------------------------------- | +| enabled | bool | はい | Telegram チャンネルを有効にするかどうか | +| token | string | はい | Telegram Bot API トークン | +| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 | +| proxy | string | いいえ | Telegram API への接続に使用するプロキシ URL (例: http://127.0.0.1:7890) | + +## セットアップ手順 + +1. Telegram で `@BotFather` を検索する +2. `/newbot` コマンドを送信し、指示に従って新しいボットを作成する +3. HTTP API トークンを取得する +4. 設定ファイルにトークンを入力する +5. (任意) `allow_from` を設定して、対話を許可するユーザー ID を制限する(ID は `@userinfobot` で取得可能) diff --git a/docs/channels/telegram/README.md b/docs/channels/telegram/README.md new file mode 100644 index 000000000..a3e057ba4 --- /dev/null +++ b/docs/channels/telegram/README.md @@ -0,0 +1,35 @@ +> Back to [README](../../../README.md) + +# 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. + +## Configuration + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", + "allow_from": ["123456789"], + "proxy": "" + } + } +} +``` + +| Field | Type | Required | Description | +| ---------- | ------ | -------- | ------------------------------------------------------------------ | +| enabled | bool | Yes | Whether to enable the Telegram channel | +| token | string | Yes | Telegram Bot API Token | +| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | +| proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) | + +## Setup + +1. Search for `@BotFather` in Telegram +2. Send the `/newbot` command and follow the prompts to create a new bot +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`) diff --git a/docs/channels/telegram/README.pt-br.md b/docs/channels/telegram/README.pt-br.md new file mode 100644 index 000000000..8d2c935b4 --- /dev/null +++ b/docs/channels/telegram/README.pt-br.md @@ -0,0 +1,35 @@ +> Voltar ao [README](../../../README.pt-br.md) + +# Telegram + +O canal Telegram utiliza long polling via a API de Bot do Telegram para comunicação baseada em bots. Suporta mensagens de texto, anexos de mídia (fotos, voz, áudio, documentos), transcrição de voz via Groq Whisper e tratamento de comandos integrado. + +## Configuração + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", + "allow_from": ["123456789"], + "proxy": "" + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| ---------- | ------ | ----------- | -------------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal Telegram deve ser habilitado | +| token | string | Sim | Token da API de Bot do Telegram | +| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários | +| proxy | string | Não | URL do proxy para conexão com a API do Telegram (ex. http://127.0.0.1:7890) | + +## Configuração inicial + +1. Pesquise por `@BotFather` no Telegram +2. Envie o comando `/newbot` e siga as instruções para criar um novo bot +3. Obtenha o Token da API HTTP +4. Preencha o Token no arquivo de configuração +5. (Opcional) Configure `allow_from` para restringir quais IDs de usuário podem interagir (os IDs podem ser obtidos via `@userinfobot`) diff --git a/docs/channels/telegram/README.vi.md b/docs/channels/telegram/README.vi.md new file mode 100644 index 000000000..858a9fc41 --- /dev/null +++ b/docs/channels/telegram/README.vi.md @@ -0,0 +1,35 @@ +> Quay lại [README](../../../README.vi.md) + +# Telegram + +Kênh Telegram sử dụng long polling qua Telegram Bot API để giao tiếp dựa trên bot. Hỗ trợ tin nhắn văn bản, tệp đính kèm đa phương tiện (ảnh, giọng nói, âm thanh, tài liệu), chuyển giọng nói thành văn bản qua Groq Whisper và xử lý lệnh tích hợp sẵn. + +## Cấu hình + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", + "allow_from": ["123456789"], + "proxy": "" + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| ---------- | ------ | -------- | ------------------------------------------------------------------------ | +| enabled | bool | Có | Có bật kênh Telegram hay không | +| token | string | Có | Token API Bot Telegram | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống nghĩa là cho phép tất cả | +| proxy | string | Không | URL proxy để kết nối với Telegram API (ví dụ: http://127.0.0.1:7890) | + +## Hướng dẫn thiết lập + +1. Tìm kiếm `@BotFather` trong Telegram +2. Gửi lệnh `/newbot` và làm theo hướng dẫn để tạo bot mới +3. Lấy Token API HTTP +4. Điền Token vào file cấu hình +5. (Tùy chọn) Cấu hình `allow_from` để giới hạn ID người dùng được phép tương tác (có thể lấy ID qua `@userinfobot`) diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md index d453c68fa..f50c712ce 100644 --- a/docs/channels/telegram/README.zh.md +++ b/docs/channels/telegram/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../../README.zh.md) + # Telegram Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、通过 Groq Whisper 进行语音转录以及内置命令处理器。 diff --git a/docs/channels/wecom/wecom_aibot/README.fr.md b/docs/channels/wecom/wecom_aibot/README.fr.md new file mode 100644 index 000000000..8020dd7b0 --- /dev/null +++ b/docs/channels/wecom/wecom_aibot/README.fr.md @@ -0,0 +1,118 @@ +> Retour au [README](../../../../README.fr.md) + +# WeCom AI Bot + +Le WeCom AI Bot est une méthode d'intégration de conversation IA officiellement fournie par WeCom. Il prend en charge les conversations privées et de groupe, intègre un protocole de réponse en streaming et supporte l'envoi proactif de la réponse finale via `response_url` en cas de dépassement de délai. + +## Comparaison avec les autres canaux WeCom + +| Fonctionnalité | WeCom Bot | WeCom App | **WeCom AI Bot** | +|----------------|-----------|-----------|-----------------| +| Chat privé | ✅ | ✅ | ✅ | +| Chat de groupe | ✅ | ❌ | ✅ | +| Sortie en streaming | ❌ | ❌ | ✅ | +| Push proactif en cas de timeout | ❌ | ✅ | ✅ | +| Complexité de configuration | Faible | Élevée | Moyenne | + +## Configuration + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "你好!有什么可以帮助你的吗?", + "max_steps": 10 + } + } +} +``` + +| Champ | Type | Requis | Description | +| ---------------- | ------ | ------ | -------------------------------------------------- | +| token | string | Oui | Jeton de vérification du callback, configuré sur la page de gestion de l'AI Bot | +| encoding_aes_key | string | Oui | Clé AES de 43 caractères, générée aléatoirement sur la page de gestion de l'AI Bot | +| webhook_path | string | Non | Chemin du webhook (par défaut : /webhook/wecom-aibot) | +| allow_from | array | Non | Liste blanche d'ID utilisateurs ; un tableau vide autorise tous les utilisateurs | +| welcome_message | string | Non | Message de bienvenue envoyé à l'ouverture du chat ; laisser vide pour désactiver | +| reply_timeout | int | Non | Délai de réponse en secondes (par défaut : 5) | +| max_steps | int | Non | Nombre maximum d'étapes d'exécution de l'agent (par défaut : 10) | + +## Procédure de configuration + +1. Connectez-vous à la [console d'administration WeCom](https://work.weixin.qq.com/wework_admin) +2. Accédez à « Gestion des applications » → « AI Bot », puis créez ou sélectionnez un AI Bot +3. Sur la page de configuration de l'AI Bot, renseignez les informations de « Réception des messages » : + - **URL** : `http://:18790/webhook/wecom-aibot` + - **Token** : Généré aléatoirement ou personnalisé + - **EncodingAESKey** : Cliquez sur « Générer aléatoirement » pour obtenir une clé de 43 caractères +4. Saisissez le Token et l'EncodingAESKey dans le fichier de configuration PicoClaw, démarrez le service, puis revenez à la console d'administration pour enregistrer (WeCom enverra une requête de vérification) + +> [!TIP] +> Le serveur doit être accessible par les serveurs WeCom. Si vous êtes sur un intranet ou en développement local, utilisez [ngrok](https://ngrok.com) ou frp pour le tunneling. + +## Protocole de réponse en streaming + +Le WeCom AI Bot utilise un protocole de « pull en streaming », différent de la réponse unique d'un webhook standard : + +``` +L'utilisateur envoie un message + │ + ▼ +PicoClaw retourne immédiatement {finish: false} (l'agent commence le traitement) + │ + ▼ +WeCom effectue un pull environ toutes les 1 seconde avec {msgtype: "stream", stream: {id: "..."}} + │ + ├─ Agent non terminé → retourne {finish: false} (continuer à attendre) + │ + └─ Agent terminé → retourne {finish: true, content: "contenu de la réponse"} +``` + +**Gestion du timeout** (tâche dépassant 30 secondes) : + +Si le traitement de l'agent dépasse environ 30 secondes (la fenêtre de polling maximale de WeCom est de 6 minutes), PicoClaw va : + +1. Fermer immédiatement le stream et afficher à l'utilisateur : « ⏳ 正在处理中,请稍候,结果将稍后发送。 » +2. L'agent continue de s'exécuter en arrière-plan +3. Une fois l'agent terminé, la réponse finale est envoyée proactivement à l'utilisateur via le `response_url` inclus dans le message + +> `response_url` est émis par WeCom, valable 1 heure, utilisable une seule fois, sans chiffrement requis — il suffit de POSTer directement le corps du message markdown. + +## Message de bienvenue + +Lorsque `welcome_message` est configuré, PicoClaw répond automatiquement avec ce message lorsqu'un utilisateur ouvre la fenêtre de chat avec l'AI Bot (événement `enter_chat`). Laisser vide pour ignorer silencieusement. + +```json +"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?" +``` + +## FAQ + +### Échec de la vérification de l'URL de callback + +- Vérifiez que le pare-feu du serveur autorise le port concerné (par défaut 18790) +- Vérifiez que `token` et `encoding_aes_key` sont correctement renseignés +- Consultez les logs PicoClaw pour voir si une requête GET de WeCom a été reçue + +### Les messages ne reçoivent pas de réponse + +- Vérifiez que `allow_from` ne restreint pas accidentellement l'expéditeur +- Recherchez `context canceled` ou des erreurs d'agent dans les logs +- Vérifiez que la configuration de l'agent (ex. `model_name`) est correcte + +### Pas de push final reçu pour les tâches longues + +- Vérifiez que le callback du message inclut `response_url` (uniquement supporté par la nouvelle version du WeCom AI Bot) +- Vérifiez que le serveur peut effectuer des requêtes sortantes (nécessite un POST vers `response_url`) +- Consultez les logs pour les mots-clés `response_url mode` et `Sending reply via response_url` + +## Références + +- [Documentation d'intégration WeCom AI Bot](https://developer.work.weixin.qq.com/document/path/100719) +- [Description du protocole de réponse en streaming](https://developer.work.weixin.qq.com/document/path/100719) +- [Réponse proactive via response_url](https://developer.work.weixin.qq.com/document/path/101138) diff --git a/docs/channels/wecom/wecom_aibot/README.ja.md b/docs/channels/wecom/wecom_aibot/README.ja.md new file mode 100644 index 000000000..210caffb4 --- /dev/null +++ b/docs/channels/wecom/wecom_aibot/README.ja.md @@ -0,0 +1,118 @@ +> [README](../../../../README.ja.md) に戻る + +# 企業WeChat AIボット + +企業WeChat AIボット(AI Bot)は、企業WeChatが公式に提供するAI会話連携方式です。プライベートチャットとグループチャットの両方をサポートし、ストリーミングレスポンスプロトコルを内蔵しており、タイムアウト後に `response_url` を通じて最終返信をプッシュする機能もサポートしています。 + +## 他のWeCom チャンネルとの比較 + +| 機能 | WeCom Bot | WeCom App | **WeCom AI Bot** | +|------|-----------|-----------|-----------------| +| プライベートチャット | ✅ | ✅ | ✅ | +| グループチャット | ✅ | ❌ | ✅ | +| ストリーミング出力 | ❌ | ❌ | ✅ | +| タイムアウト時のプッシュ | ❌ | ✅ | ✅ | +| 設定の複雑さ | 低 | 高 | 中 | + +## 設定 + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "你好!有什么可以帮助你的吗?", + "max_steps": 10 + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| ---------------- | ------ | ---- | -------------------------------------------------- | +| token | string | はい | コールバック検証トークン。AIボット管理ページで設定 | +| encoding_aes_key | string | はい | 43文字のAESキー。AIボット管理ページでランダム生成 | +| webhook_path | string | いいえ | Webhookパス(デフォルト:/webhook/wecom-aibot) | +| allow_from | array | いいえ | ユーザーIDの許可リスト。空配列は全ユーザーを許可 | +| welcome_message | string | いいえ | ユーザーがチャットを開いたときに送信するウェルカムメッセージ。空白の場合は送信しない | +| reply_timeout | int | いいえ | 返信タイムアウト(秒、デフォルト:5) | +| max_steps | int | いいえ | エージェントの最大実行ステップ数(デフォルト:10) | + +## セットアップ手順 + +1. [企業WeChat管理コンソール](https://work.weixin.qq.com/wework_admin) にログイン +2. 「アプリ管理」→「AIボット」に進み、AIボットを作成または選択 +3. AIボット設定ページで「メッセージ受信」情報を入力: + - **URL**:`http://:18790/webhook/wecom-aibot` + - **Token**:ランダム生成またはカスタム + - **EncodingAESKey**:「ランダム生成」をクリックして43文字のキーを取得 +4. TokenとEncodingAESKeyをPicoClawの設定ファイルに入力し、サービスを起動してから管理コンソールに戻って保存(企業WeChatが検証リクエストを送信します) + +> [!TIP] +> サーバーは企業WeChatのサーバーからアクセス可能である必要があります。イントラネットやローカル開発環境の場合は、[ngrok](https://ngrok.com) またはfrpを使用してトンネリングしてください。 + +## ストリーミングレスポンスプロトコル + +WeCom AIボットは「ストリーミングプル」プロトコルを使用しており、通常のWebhookの一回限りの返信とは異なります: + +``` +ユーザーがメッセージを送信 + │ + ▼ +PicoClawが即座に {finish: false} を返す(エージェントが処理開始) + │ + ▼ +企業WeChatが約1秒ごとに {msgtype: "stream", stream: {id: "..."}} でプル + │ + ├─ エージェント未完了 → {finish: false} を返す(待機継続) + │ + └─ エージェント完了 → {finish: true, content: "返信内容"} を返す +``` + +**タイムアウト処理**(タスクが30秒を超える場合): + +エージェントの処理時間が約30秒を超えた場合(企業WeChatの最大ポーリングウィンドウは6分)、PicoClawは: + +1. 即座にストリームを閉じ、ユーザーに「⏳ 正在处理中,请稍候,结果将稍后发送。」と表示 +2. エージェントはバックグラウンドで処理を継続 +3. エージェント完了後、メッセージに含まれる `response_url` を通じて最終返信をユーザーにプッシュ + +> `response_url` は企業WeChatが発行し、有効期限は1時間、使用は1回限りで、暗号化不要。マークダウンメッセージ本文をそのままPOSTするだけです。 + +## ウェルカムメッセージ + +`welcome_message` を設定すると、ユーザーがAIボットとのチャットウィンドウを開いたとき(`enter_chat` イベント)に、PicoClawが自動的にそのメッセージを返信します。空白の場合は無視されます。 + +```json +"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?" +``` + +## よくある質問 + +### コールバックURL検証の失敗 + +- サーバーのファイアウォールで該当ポートが開放されているか確認(デフォルト18790) +- `token` と `encoding_aes_key` が正しく入力されているか確認 +- PicoClawのログに企業WeChatからのGETリクエストが届いているか確認 + +### メッセージに返信がない + +- `allow_from` が誤って送信者を制限していないか確認 +- ログに `context canceled` またはエージェントエラーが出ていないか確認 +- エージェント設定(`model_name` など)が正しいか確認 + +### 長時間タスクで最終プッシュが届かない + +- メッセージコールバックに `response_url` が含まれているか確認(新バージョンの企業WeChat AIボットのみ対応) +- サーバーが外部ネットワークへのアウトバウンドリクエストを送信できるか確認(`response_url` へのPOSTが必要) +- ログのキーワード `response_url mode` と `Sending reply via response_url` を確認 + +## 参考ドキュメント + +- [企業WeChat AIボット連携ドキュメント](https://developer.work.weixin.qq.com/document/path/100719) +- [ストリーミングレスポンスプロトコルの説明](https://developer.work.weixin.qq.com/document/path/100719) +- [response_url によるプロアクティブ返信](https://developer.work.weixin.qq.com/document/path/101138) diff --git a/docs/channels/wecom/wecom_aibot/README.md b/docs/channels/wecom/wecom_aibot/README.md new file mode 100644 index 000000000..31d831617 --- /dev/null +++ b/docs/channels/wecom/wecom_aibot/README.md @@ -0,0 +1,118 @@ +> Back to [README](../../../../README.md) + +# WeCom AI Bot + +The WeCom AI Bot is an official AI conversation integration provided by WeCom. It supports both private and group chats, has a built-in streaming response protocol, and supports proactively pushing the final reply via `response_url` after a timeout. + +## Comparison with Other WeCom Channels + +| Feature | WeCom Bot | WeCom App | **WeCom AI Bot** | +|---------|-----------|-----------|-----------------| +| Private Chat | ✅ | ✅ | ✅ | +| Group Chat | ✅ | ❌ | ✅ | +| Streaming Output | ❌ | ❌ | ✅ | +| Proactive Push on Timeout | ❌ | ✅ | ✅ | +| Configuration Complexity | Low | High | Medium | + +## Configuration + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "你好!有什么可以帮助你的吗?", + "max_steps": 10 + } + } +} +``` + +| Field | Type | Required | Description | +| ---------------- | ------ | -------- | -------------------------------------------------- | +| token | string | Yes | Callback verification token, configured on the AI Bot management page | +| encoding_aes_key | string | Yes | 43-character AES key, randomly generated on the AI Bot management page | +| webhook_path | string | No | Webhook path (default: /webhook/wecom-aibot) | +| allow_from | array | No | User ID allowlist; empty array allows all users | +| welcome_message | string | No | Welcome message sent when a user opens the chat; leave empty to disable | +| reply_timeout | int | No | Reply timeout in seconds (default: 5) | +| max_steps | int | No | Maximum agent execution steps (default: 10) | + +## Setup + +1. Log in to the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin) +2. Go to "App Management" → "AI Bot", then create or select an AI Bot +3. On the AI Bot configuration page, fill in the "Message Reception" details: + - **URL**: `http://:18790/webhook/wecom-aibot` + - **Token**: Randomly generated or custom + - **EncodingAESKey**: Click "Random Generate" to get a 43-character key +4. Enter the Token and EncodingAESKey into the PicoClaw config file, start the service, then return to the admin console to save (WeCom will send a verification request) + +> [!TIP] +> The server must be accessible by WeCom's servers. If you are on an intranet or developing locally, use [ngrok](https://ngrok.com) or frp for tunneling. + +## Streaming Response Protocol + +WeCom AI Bot uses a "streaming pull" protocol, which differs from the one-shot reply of a standard webhook: + +``` +User sends a message + │ + ▼ +PicoClaw immediately returns {finish: false} (Agent starts processing) + │ + ▼ +WeCom pulls approximately every 1 second with {msgtype: "stream", stream: {id: "..."}} + │ + ├─ Agent not done → returns {finish: false} (keep waiting) + │ + └─ Agent done → returns {finish: true, content: "reply content"} +``` + +**Timeout Handling** (task exceeds 30 seconds): + +If the Agent takes longer than approximately 30 seconds (WeCom's maximum polling window is 6 minutes), PicoClaw will: + +1. Immediately close the stream and show the user: "⏳ 正在处理中,请稍候,结果将稍后发送。" +2. The Agent continues running in the background +3. Once the Agent finishes, the final reply is proactively pushed to the user via the `response_url` included in the message + +> `response_url` is issued by WeCom, valid for 1 hour, can only be used once, requires no encryption — just POST the markdown message body directly. + +## Welcome Message + +When `welcome_message` is configured, PicoClaw will automatically reply with it when a user opens the chat window with the AI Bot (`enter_chat` event). Leave it empty to silently ignore the event. + +```json +"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?" +``` + +## FAQ + +### Callback URL Verification Failed + +- Confirm the server firewall has the relevant port open (default 18790) +- Confirm `token` and `encoding_aes_key` are entered correctly +- Check PicoClaw logs to see if a GET request from WeCom was received + +### Messages Not Getting a Reply + +- Check whether `allow_from` is accidentally restricting the sender +- Look for `context canceled` or Agent errors in the logs +- Confirm the Agent configuration (e.g., `model_name`) is correct + +### No Final Push Received for Long-Running Tasks + +- Confirm the message callback includes `response_url` (only supported by the newer WeCom AI Bot) +- Confirm the server can make outbound requests (needs to POST to `response_url`) +- Check logs for keywords `response_url mode` and `Sending reply via response_url` + +## Reference + +- [WeCom AI Bot Integration Docs](https://developer.work.weixin.qq.com/document/path/100719) +- [Streaming Response Protocol](https://developer.work.weixin.qq.com/document/path/100719) +- [Proactive Reply via response_url](https://developer.work.weixin.qq.com/document/path/101138) diff --git a/docs/channels/wecom/wecom_aibot/README.pt-br.md b/docs/channels/wecom/wecom_aibot/README.pt-br.md new file mode 100644 index 000000000..1ab735c41 --- /dev/null +++ b/docs/channels/wecom/wecom_aibot/README.pt-br.md @@ -0,0 +1,118 @@ +> Voltar ao [README](../../../../README.pt-br.md) + +# WeCom AI Bot + +O WeCom AI Bot é uma forma oficial de integração de conversas com IA fornecida pelo WeCom. Suporta conversas privadas e em grupo, possui um protocolo de resposta em streaming integrado e suporta o envio proativo da resposta final via `response_url` após um timeout. + +## Comparação com outros canais WeCom + +| Recurso | WeCom Bot | WeCom App | **WeCom AI Bot** | +|---------|-----------|-----------|-----------------| +| Chat privado | ✅ | ✅ | ✅ | +| Chat em grupo | ✅ | ❌ | ✅ | +| Saída em streaming | ❌ | ❌ | ✅ | +| Push proativo em timeout | ❌ | ✅ | ✅ | +| Complexidade de configuração | Baixa | Alta | Média | + +## Configuração + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "你好!有什么可以帮助你的吗?", + "max_steps": 10 + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| ---------------- | ------ | ----------- | -------------------------------------------------- | +| token | string | Sim | Token de verificação de callback, configurado na página de gerenciamento do AI Bot | +| encoding_aes_key | string | Sim | Chave AES de 43 caracteres, gerada aleatoriamente na página de gerenciamento do AI Bot | +| webhook_path | string | Não | Caminho do webhook (padrão: /webhook/wecom-aibot) | +| allow_from | array | Não | Lista de permissão de IDs de usuários; array vazio permite todos os usuários | +| welcome_message | string | Não | Mensagem de boas-vindas enviada quando o usuário abre o chat; deixe vazio para desativar | +| reply_timeout | int | Não | Timeout de resposta em segundos (padrão: 5) | +| max_steps | int | Não | Número máximo de etapas de execução do agente (padrão: 10) | + +## Configuração passo a passo + +1. Faça login no [Console de Administração do WeCom](https://work.weixin.qq.com/wework_admin) +2. Acesse "Gerenciamento de Apps" → "AI Bot", depois crie ou selecione um AI Bot +3. Na página de configuração do AI Bot, preencha as informações de "Recebimento de Mensagens": + - **URL**: `http://:18790/webhook/wecom-aibot` + - **Token**: Gerado aleatoriamente ou personalizado + - **EncodingAESKey**: Clique em "Gerar Aleatoriamente" para obter uma chave de 43 caracteres +4. Insira o Token e o EncodingAESKey no arquivo de configuração do PicoClaw, inicie o serviço e volte ao console de administração para salvar (o WeCom enviará uma requisição de verificação) + +> [!TIP] +> O servidor precisa ser acessível pelos servidores do WeCom. Se estiver em uma intranet ou desenvolvendo localmente, use [ngrok](https://ngrok.com) ou frp para tunelamento. + +## Protocolo de resposta em streaming + +O WeCom AI Bot usa um protocolo de "pull em streaming", diferente da resposta única de um webhook padrão: + +``` +Usuário envia uma mensagem + │ + ▼ +PicoClaw retorna imediatamente {finish: false} (Agente começa a processar) + │ + ▼ +WeCom faz pull aproximadamente a cada 1 segundo com {msgtype: "stream", stream: {id: "..."}} + │ + ├─ Agente não concluído → retorna {finish: false} (continuar aguardando) + │ + └─ Agente concluído → retorna {finish: true, content: "conteúdo da resposta"} +``` + +**Tratamento de timeout** (tarefa excede 30 segundos): + +Se o processamento do agente demorar mais de aproximadamente 30 segundos (a janela máxima de polling do WeCom é de 6 minutos), o PicoClaw irá: + +1. Fechar imediatamente o stream e exibir ao usuário: "⏳ 正在处理中,请稍候,结果将稍后发送。" +2. O agente continua executando em segundo plano +3. Após a conclusão do agente, a resposta final é enviada proativamente ao usuário via `response_url` incluído na mensagem + +> `response_url` é emitido pelo WeCom, válido por 1 hora, pode ser usado apenas uma vez, sem necessidade de criptografia — basta fazer um POST com o corpo da mensagem em markdown diretamente. + +## Mensagem de boas-vindas + +Quando `welcome_message` está configurado, o PicoClaw responde automaticamente com essa mensagem quando um usuário abre a janela de chat com o AI Bot (evento `enter_chat`). Deixe vazio para ignorar silenciosamente. + +```json +"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?" +``` + +## Perguntas frequentes + +### Falha na verificação da URL de callback + +- Confirme que o firewall do servidor tem a porta correspondente aberta (padrão 18790) +- Confirme que `token` e `encoding_aes_key` estão preenchidos corretamente +- Verifique os logs do PicoClaw para ver se uma requisição GET do WeCom foi recebida + +### Mensagens sem resposta + +- Verifique se `allow_from` está restringindo acidentalmente o remetente +- Procure por `context canceled` ou erros do agente nos logs +- Confirme que a configuração do agente (ex.: `model_name`) está correta + +### Nenhum push final recebido para tarefas longas + +- Confirme que o callback da mensagem inclui `response_url` (suportado apenas pelo novo WeCom AI Bot) +- Confirme que o servidor consegue fazer requisições de saída (precisa fazer POST para `response_url`) +- Verifique nos logs as palavras-chave `response_url mode` e `Sending reply via response_url` + +## Referências + +- [Documentação de integração do WeCom AI Bot](https://developer.work.weixin.qq.com/document/path/100719) +- [Descrição do protocolo de resposta em streaming](https://developer.work.weixin.qq.com/document/path/100719) +- [Resposta proativa via response_url](https://developer.work.weixin.qq.com/document/path/101138) diff --git a/docs/channels/wecom/wecom_aibot/README.vi.md b/docs/channels/wecom/wecom_aibot/README.vi.md new file mode 100644 index 000000000..cb6586e6e --- /dev/null +++ b/docs/channels/wecom/wecom_aibot/README.vi.md @@ -0,0 +1,118 @@ +> Quay lại [README](../../../../README.vi.md) + +# WeCom AI Bot + +WeCom AI Bot là phương thức tích hợp hội thoại AI chính thức do WeCom cung cấp. Hỗ trợ cả chat riêng tư và chat nhóm, tích hợp giao thức phản hồi streaming, và hỗ trợ chủ động đẩy phản hồi cuối cùng qua `response_url` sau khi hết thời gian chờ. + +## So sánh với các kênh WeCom khác + +| Tính năng | WeCom Bot | WeCom App | **WeCom AI Bot** | +|-----------|-----------|-----------|-----------------| +| Chat riêng tư | ✅ | ✅ | ✅ | +| Chat nhóm | ✅ | ❌ | ✅ | +| Đầu ra streaming | ❌ | ❌ | ✅ | +| Đẩy chủ động khi timeout | ❌ | ✅ | ✅ | +| Độ phức tạp cấu hình | Thấp | Cao | Trung bình | + +## Cấu hình + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "你好!有什么可以帮助你的吗?", + "max_steps": 10 + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| ---------------- | ------ | --------- | -------------------------------------------------- | +| token | string | Có | Token xác minh callback, cấu hình trên trang quản lý AI Bot | +| encoding_aes_key | string | Có | Khóa AES 43 ký tự, được tạo ngẫu nhiên trên trang quản lý AI Bot | +| webhook_path | string | Không | Đường dẫn webhook (mặc định: /webhook/wecom-aibot) | +| allow_from | array | Không | Danh sách cho phép ID người dùng; mảng rỗng cho phép tất cả người dùng | +| welcome_message | string | Không | Tin nhắn chào mừng gửi khi người dùng mở chat; để trống để tắt | +| reply_timeout | int | Không | Thời gian chờ phản hồi tính bằng giây (mặc định: 5) | +| max_steps | int | Không | Số bước thực thi tối đa của agent (mặc định: 10) | + +## Hướng dẫn thiết lập + +1. Đăng nhập vào [Bảng điều khiển quản trị WeCom](https://work.weixin.qq.com/wework_admin) +2. Vào "Quản lý ứng dụng" → "AI Bot", sau đó tạo hoặc chọn một AI Bot +3. Trên trang cấu hình AI Bot, điền thông tin "Nhận tin nhắn": + - **URL**: `http://:18790/webhook/wecom-aibot` + - **Token**: Tạo ngẫu nhiên hoặc tùy chỉnh + - **EncodingAESKey**: Nhấp "Tạo ngẫu nhiên" để lấy khóa 43 ký tự +4. Nhập Token và EncodingAESKey vào file cấu hình PicoClaw, khởi động dịch vụ rồi quay lại bảng điều khiển quản trị để lưu (WeCom sẽ gửi yêu cầu xác minh) + +> [!TIP] +> Máy chủ cần có thể truy cập được từ các máy chủ WeCom. Nếu bạn đang ở mạng nội bộ hoặc phát triển cục bộ, hãy sử dụng [ngrok](https://ngrok.com) hoặc frp để tạo tunnel. + +## Giao thức phản hồi streaming + +WeCom AI Bot sử dụng giao thức "pull streaming", khác với phản hồi một lần của webhook thông thường: + +``` +Người dùng gửi tin nhắn + │ + ▼ +PicoClaw trả về ngay {finish: false} (Agent bắt đầu xử lý) + │ + ▼ +WeCom pull khoảng mỗi 1 giây với {msgtype: "stream", stream: {id: "..."}} + │ + ├─ Agent chưa xong → trả về {finish: false} (tiếp tục chờ) + │ + └─ Agent xong → trả về {finish: true, content: "nội dung phản hồi"} +``` + +**Xử lý timeout** (tác vụ vượt quá 30 giây): + +Nếu thời gian xử lý của agent vượt quá khoảng 30 giây (cửa sổ polling tối đa của WeCom là 6 phút), PicoClaw sẽ: + +1. Đóng stream ngay lập tức và hiển thị cho người dùng: "⏳ 正在处理中,请稍候,结果将稍后发送。" +2. Agent tiếp tục chạy ở nền +3. Sau khi agent hoàn thành, phản hồi cuối cùng được chủ động đẩy đến người dùng qua `response_url` có trong tin nhắn + +> `response_url` do WeCom cấp, có hiệu lực 1 giờ, chỉ dùng được một lần, không cần mã hóa — chỉ cần POST trực tiếp nội dung tin nhắn markdown. + +## Tin nhắn chào mừng + +Khi `welcome_message` được cấu hình, PicoClaw sẽ tự động phản hồi bằng tin nhắn đó khi người dùng mở cửa sổ chat với AI Bot (sự kiện `enter_chat`). Để trống để bỏ qua im lặng. + +```json +"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?" +``` + +## Câu hỏi thường gặp + +### Xác minh URL callback thất bại + +- Xác nhận tường lửa máy chủ đã mở cổng tương ứng (mặc định 18790) +- Xác nhận `token` và `encoding_aes_key` được điền đúng +- Kiểm tra log PicoClaw xem có nhận được yêu cầu GET từ WeCom không + +### Tin nhắn không nhận được phản hồi + +- Kiểm tra xem `allow_from` có vô tình hạn chế người gửi không +- Tìm `context canceled` hoặc lỗi agent trong log +- Xác nhận cấu hình agent (ví dụ: `model_name`) là đúng + +### Không nhận được push cuối cùng cho tác vụ dài + +- Xác nhận callback tin nhắn có chứa `response_url` (chỉ hỗ trợ bởi WeCom AI Bot phiên bản mới) +- Xác nhận máy chủ có thể thực hiện yêu cầu ra ngoài (cần POST đến `response_url`) +- Kiểm tra log với từ khóa `response_url mode` và `Sending reply via response_url` + +## Tài liệu tham khảo + +- [Tài liệu tích hợp WeCom AI Bot](https://developer.work.weixin.qq.com/document/path/100719) +- [Mô tả giao thức phản hồi streaming](https://developer.work.weixin.qq.com/document/path/100719) +- [Phản hồi chủ động qua response_url](https://developer.work.weixin.qq.com/document/path/101138) diff --git a/docs/channels/wecom/wecom_aibot/README.zh.md b/docs/channels/wecom/wecom_aibot/README.zh.md index d210528af..9da5ee1b9 100644 --- a/docs/channels/wecom/wecom_aibot/README.zh.md +++ b/docs/channels/wecom/wecom_aibot/README.zh.md @@ -1,6 +1,11 @@ +> 返回 [README](../../../../README.zh.md) + # 企业微信智能机器人 (AI Bot) -企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议,并支持超时后通过 `response_url` 主动推送最终回复。 +企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议。PicoClaw 当前同时支持两种接入模式: + +- WebSocket 长连接模式:使用 `bot_id` + `secret`,优先级更高,推荐使用 +- Webhook 短连接模式:使用 `token` + `encoding_aes_key`,兼容传统回调,并支持超时后通过 `response_url` 主动推送最终回复 ## 与其他 WeCom 通道的对比 @@ -14,6 +19,25 @@ ## 配置 +### WebSocket 长连接模式(推荐) + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "bot_id": "YOUR_BOT_ID", + "secret": "YOUR_SECRET", + "allow_from": [], + "welcome_message": "你好!有什么可以帮助你的吗?", + "max_steps": 10 + } + } +} +``` + +### Webhook 短连接模式 + ```json { "channels": { @@ -24,38 +48,68 @@ "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "你好!有什么可以帮助你的吗?", + "processing_message": "⏳ Processing, please wait. The results will be sent shortly.", "max_steps": 10 } } } ``` -| 字段 | 类型 | 必填 | 描述 | -| ---------------- | ------ | ---- | -------------------------------------------------- | -| token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 | -| encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 | -| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-aibot) | -| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 | -| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 | -| reply_timeout | int | 否 | 回复超时时间(秒,默认:5) | -| max_steps | int | 否 | Agent 最大执行步骤数(默认:10) | +### WebSocket 模式字段 + +| 字段 | 类型 | 必填 | 描述 | +|--------|--------|------|--------------------------------------------| +| bot_id | string | 是 | AI Bot 的唯一标识,在 AI Bot 管理页面配置 | +| secret | string | 是 | AI Bot 的密钥,在 AI Bot 管理页面配置 | + +### Webhook 模式字段 + +| 字段 | 类型 | 必填 | 描述 | +|------------------|--------|------|----------------------------------------------| +| token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 | +| encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 | +| webhook_path | string | 否 | Webhook 路径,默认 `/webhook/wecom-aibot` | +| processing_message | string | 否 | 流式超时后返回给用户的提示语 | + +### 通用字段 + +| 字段 | 类型 | 必填 | 描述 | +|-----------------|--------|------|------------------------------------------| +| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 | +| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 | +| reply_timeout | int | 否 | 回复超时时间(秒,默认:5) | +| max_steps | int | 否 | Agent 最大执行步骤数(默认:10) | + +## 模式选择 + +- 当 `bot_id` 和 `secret` 同时存在时,PicoClaw 会优先使用 WebSocket 长连接模式 +- 否则,当 `token` 和 `encoding_aes_key` 同时存在时,PicoClaw 会使用 Webhook 短连接模式 ## 设置流程 +### WebSocket 长连接模式 + +1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin) +2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot +3. 在 AI Bot 配置页面,配置 Bot 的名称、头像等信息,获取 `Bot ID` 和 `Secret` +4. 在 PicoClaw 配置文件中添加上述配置,重启 PicoClaw + +### Webhook 短连接模式 + 1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin) 2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot 3. 在 AI Bot 配置页面,填写"消息接收"信息: - - **URL**:`http://:18791/webhook/wecom-aibot` + - **URL**:`http://:18790/webhook/wecom-aibot` - **Token**:随机生成或自定义 - **EncodingAESKey**:点击"随机生成",得到 43 字符密钥 -4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件,启动服务后回到管理后台保存(企业微信会发送验证请求) +4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件,启动服务后回到管理后台保存 > [!TIP] -> 服务器需要能被企业微信服务器访问。如在内网/本地开发,可使用 [ngrok](https://ngrok.com) 或 frp 做内网穿透。 +> 服务器需要能被企业微信服务器访问。如在内网或本地开发,可使用 [ngrok](https://ngrok.com) 或 frp 做内网穿透。 -## 流式响应协议 +## Webhook 模式的流式响应协议 -WeCom AI Bot 使用"流式拉取"协议,区别于普通 Webhook 的一次性回复: +Webhook 模式使用"流式拉取"协议,区别于普通 Webhook 的一次性回复: ``` 用户发消息 @@ -71,16 +125,24 @@ PicoClaw 立即返回 {finish: false}(Agent 开始处理) └─ Agent 完成 → 返回 {finish: true, content: "回答内容"} ``` -**超时处理**(任务超过 30 秒): +**超时处理**(任务超过约 30 秒): -若 Agent 处理时间超过约 30 秒(企业微信最大轮询窗口为 6 分钟),PicoClaw 会: +若 Agent 处理时间超过轮询窗口,PicoClaw 会: -1. 立即关闭流,向用户显示「⏳ 正在处理中,请稍候,结果将稍后发送。」 +1. 立即关闭流,向用户显示 `processing_message` 提示语 2. Agent 继续在后台运行 3. Agent 完成后,通过消息中携带的 `response_url` 将最终回复主动推送给用户 > `response_url` 由企业微信颁发,有效期 1 小时,只可使用一次,无需加密,直接 POST markdown 消息体即可。 +## 超时提示语 + +配置 `processing_message` 后,当 Webhook 模式的流式轮询超时并切换到 `response_url` 主动推送模式时,PicoClaw 会先返回这段提示语来结束当前流。 + +```json +"processing_message": "⏳ Processing, please wait. The results will be sent shortly." +``` + ## 欢迎语 配置 `welcome_message` 后,当用户打开与 AI Bot 的聊天窗口时(`enter_chat` 事件),PicoClaw 会自动回复该欢迎语。留空则静默忽略。 @@ -91,11 +153,18 @@ PicoClaw 立即返回 {finish: false}(Agent 开始处理) ## 常见问题 +### WebSocket 模式无法连接 + +- 检查 `bot_id` 和 `secret` 是否填写正确 +- 查看日志中是否有 WebSocket 连接或鉴权失败信息 +- 确认服务器可以访问企业微信长连接接口 + ### 回调 URL 验证失败 -- 确认服务器防火墙已开放对应端口(默认 18791) + - 确认 `token` 与 `encoding_aes_key` 填写正确 -- 检查 PicoClaw 日志是否收到了来自企业微信的 GET 请求 +- 确认服务器防火墙已开放对应端口 +- 检查 PicoClaw 日志是否收到了来自企业微信的验证请求 ### 消息没有回复 @@ -105,12 +174,12 @@ PicoClaw 立即返回 {finish: false}(Agent 开始处理) ### 超长任务没有收到最终推送 -- 确认消息回调中携带了 `response_url`(仅企业微信新版 AI Bot 支持) -- 确认服务器能主动访问外网(需向 `response_url` POST 请求) +- 确认消息回调中携带了 `response_url` +- 确认服务器能主动访问外网 - 查看日志关键词 `response_url mode` 和 `Sending reply via response_url` ## 参考文档 -- [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/100719) +- [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/101463) - [流式响应协议说明](https://developer.work.weixin.qq.com/document/path/100719) - [response_url 主动回复](https://developer.work.weixin.qq.com/document/path/101138) diff --git a/docs/channels/wecom/wecom_app/README.fr.md b/docs/channels/wecom/wecom_app/README.fr.md new file mode 100644 index 000000000..f95426497 --- /dev/null +++ b/docs/channels/wecom/wecom_app/README.fr.md @@ -0,0 +1,47 @@ +> Retour au [README](../../../../README.fr.md) + +# Application interne WeCom + +Une application interne WeCom est une application créée par une entreprise au sein de WeCom, principalement destinée à un usage interne. Grâce aux applications internes WeCom, les entreprises peuvent assurer une communication et une collaboration efficaces avec leurs employés, améliorant ainsi la productivité. + +## Configuration + +```json +{ + "channels": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [], + "reply_timeout": 5 + } + } +} +``` + +| Champ | Type | Requis | Description | +| ---------------- | ------ | ------ | ---------------------------------------- | +| corp_id | string | Oui | ID de l'entreprise | +| corp_secret | string | Oui | Secret de l'application | +| agent_id | int | Oui | ID de l'agent de l'application | +| token | string | Oui | Jeton de vérification du callback | +| encoding_aes_key | string | Oui | Clé AES de 43 caractères | +| webhook_path | string | Non | Chemin du webhook (par défaut : /webhook/wecom-app) | +| allow_from | array | Non | Liste blanche d'ID utilisateurs | +| reply_timeout | int | Non | Délai de réponse en secondes | + +## Procédure de configuration + +1. Connectez-vous à la [console d'administration WeCom](https://work.weixin.qq.com/) +2. Accédez à « Gestion des applications » -> « Créer une application » +3. Obtenez l'ID d'entreprise (CorpID) et le Secret de l'application +4. Configurez « Réception des messages » dans les paramètres de l'application pour obtenir le Token et l'EncodingAESKey +5. Définissez l'URL de callback sur `http://:/webhook/wecom-app` +6. Saisissez le CorpID, le Secret, l'AgentID et les autres informations dans le fichier de configuration + + Remarque : PicoClaw utilise désormais un serveur HTTP Gateway partagé pour recevoir les callbacks webhook de tous les canaux. L'adresse d'écoute par défaut est 127.0.0.1:18790. Pour recevoir des callbacks depuis l'internet public, configurez un reverse proxy de votre domaine externe vers le Gateway (port par défaut 18790). diff --git a/docs/channels/wecom/wecom_app/README.ja.md b/docs/channels/wecom/wecom_app/README.ja.md new file mode 100644 index 000000000..4bd5a7101 --- /dev/null +++ b/docs/channels/wecom/wecom_app/README.ja.md @@ -0,0 +1,47 @@ +> [README](../../../../README.ja.md) に戻る + +# 企業WeChat 自社開発アプリ + +企業WeChat 自社開発アプリとは、企業が企業WeChat内で作成するアプリケーションで、主に社内利用を目的としています。企業WeChat 自社開発アプリを通じて、企業は従業員との効率的なコミュニケーションと協業を実現し、業務効率を向上させることができます。 + +## 設定 + +```json +{ + "channels": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [], + "reply_timeout": 5 + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| ---------------- | ------ | ---- | ---------------------------------------- | +| corp_id | string | はい | 企業ID | +| corp_secret | string | はい | アプリケーションシークレット | +| agent_id | int | はい | アプリケーションエージェントID | +| token | string | はい | コールバック検証トークン | +| encoding_aes_key | string | はい | 43文字のAESキー | +| webhook_path | string | いいえ | Webhookパス(デフォルト:/webhook/wecom-app) | +| allow_from | array | いいえ | ユーザーIDの許可リスト | +| reply_timeout | int | いいえ | 返信タイムアウト(秒) | + +## セットアップ手順 + +1. [企業WeChat管理コンソール](https://work.weixin.qq.com/) にログイン +2. 「アプリ管理」→「アプリを作成」に進む +3. 企業ID(CorpID)とアプリのSecretを取得 +4. アプリ設定で「メッセージ受信」を設定し、TokenとEncodingAESKeyを取得 +5. コールバックURLを `http://:/webhook/wecom-app` に設定 +6. CorpID、Secret、AgentIDなどの情報を設定ファイルに入力 + + 注意:PicoClawは現在、すべてのチャンネルのwebhookコールバックを受信するために共有のGateway HTTPサーバーを使用しています。デフォルトのリスニングアドレスは127.0.0.1:18790です。公共インターネットからコールバックを受信するには、外部ドメインをGateway(デフォルトポート18790)にリバースプロキシしてください。 diff --git a/docs/channels/wecom/wecom_app/README.md b/docs/channels/wecom/wecom_app/README.md new file mode 100644 index 000000000..4397f805a --- /dev/null +++ b/docs/channels/wecom/wecom_app/README.md @@ -0,0 +1,47 @@ +> Back to [README](../../../../README.md) + +# WeCom Internal App + +A WeCom Internal App is an application created by an enterprise within WeCom, primarily intended for internal use. Through WeCom Internal Apps, enterprises can achieve efficient communication and collaboration with employees, improving productivity. + +## Configuration + +```json +{ + "channels": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [], + "reply_timeout": 5 + } + } +} +``` + +| Field | Type | Required | Description | +| ---------------- | ------ | -------- | ---------------------------------------- | +| corp_id | string | Yes | Enterprise ID | +| corp_secret | string | Yes | Application secret | +| agent_id | int | Yes | Application agent ID | +| token | string | Yes | Callback verification token | +| encoding_aes_key | string | Yes | 43-character AES key | +| webhook_path | string | No | Webhook path (default: /webhook/wecom-app) | +| allow_from | array | No | User ID allowlist | +| reply_timeout | int | No | Reply timeout in seconds | + +## Setup + +1. Log in to the [WeCom Admin Console](https://work.weixin.qq.com/) +2. Go to "App Management" -> "Create App" +3. Obtain the Enterprise ID (CorpID) and App Secret +4. Configure "Receive Messages" in the app settings to get the Token and EncodingAESKey +5. Set the callback URL to `http://:/webhook/wecom-app` +6. Enter the CorpID, Secret, AgentID, and other details into the config file + + Note: PicoClaw now uses a shared Gateway HTTP server to receive webhook callbacks for all channels. The default listening address is 127.0.0.1:18790. To receive callbacks from the public internet, reverse-proxy your external domain to the Gateway (default port 18790). diff --git a/docs/channels/wecom/wecom_app/README.pt-br.md b/docs/channels/wecom/wecom_app/README.pt-br.md new file mode 100644 index 000000000..bd0538ed0 --- /dev/null +++ b/docs/channels/wecom/wecom_app/README.pt-br.md @@ -0,0 +1,47 @@ +> Voltar ao [README](../../../../README.pt-br.md) + +# App Interno WeCom + +Um App Interno WeCom é um aplicativo criado por uma empresa dentro do WeCom, destinado principalmente ao uso interno. Por meio dos Apps Internos WeCom, as empresas podem alcançar comunicação e colaboração eficientes com os funcionários, melhorando a produtividade. + +## Configuração + +```json +{ + "channels": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [], + "reply_timeout": 5 + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| ---------------- | ------ | ----------- | ---------------------------------------- | +| corp_id | string | Sim | ID da empresa | +| corp_secret | string | Sim | Segredo da aplicação | +| agent_id | int | Sim | ID do agente da aplicação | +| token | string | Sim | Token de verificação de callback | +| encoding_aes_key | string | Sim | Chave AES de 43 caracteres | +| webhook_path | string | Não | Caminho do webhook (padrão: /webhook/wecom-app) | +| allow_from | array | Não | Lista de permissão de IDs de usuários | +| reply_timeout | int | Não | Timeout de resposta em segundos | + +## Configuração passo a passo + +1. Faça login no [Console de Administração do WeCom](https://work.weixin.qq.com/) +2. Acesse "Gerenciamento de Apps" -> "Criar App" +3. Obtenha o ID da Empresa (CorpID) e o Secret do App +4. Configure "Receber Mensagens" nas configurações do app para obter o Token e o EncodingAESKey +5. Defina a URL de callback como `http://:/webhook/wecom-app` +6. Insira o CorpID, Secret, AgentID e outras informações no arquivo de configuração + + Nota: O PicoClaw agora usa um servidor HTTP Gateway compartilhado para receber callbacks de webhook de todos os canais. O endereço de escuta padrão é 127.0.0.1:18790. Para receber callbacks da internet pública, configure um reverse proxy do seu domínio externo para o Gateway (porta padrão 18790). diff --git a/docs/channels/wecom/wecom_app/README.vi.md b/docs/channels/wecom/wecom_app/README.vi.md new file mode 100644 index 000000000..f713f9501 --- /dev/null +++ b/docs/channels/wecom/wecom_app/README.vi.md @@ -0,0 +1,47 @@ +> Quay lại [README](../../../../README.vi.md) + +# Ứng dụng nội bộ WeCom + +Ứng dụng nội bộ WeCom là ứng dụng được doanh nghiệp tạo ra trong WeCom, chủ yếu dùng cho mục đích nội bộ. Thông qua ứng dụng nội bộ WeCom, doanh nghiệp có thể thực hiện giao tiếp và cộng tác hiệu quả với nhân viên, nâng cao hiệu suất làm việc. + +## Cấu hình + +```json +{ + "channels": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [], + "reply_timeout": 5 + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| ---------------- | ------ | --------- | ---------------------------------------- | +| corp_id | string | Có | ID doanh nghiệp | +| corp_secret | string | Có | Secret của ứng dụng | +| agent_id | int | Có | ID agent của ứng dụng | +| token | string | Có | Token xác minh callback | +| encoding_aes_key | string | Có | Khóa AES 43 ký tự | +| webhook_path | string | Không | Đường dẫn webhook (mặc định: /webhook/wecom-app) | +| allow_from | array | Không | Danh sách cho phép ID người dùng | +| reply_timeout | int | Không | Thời gian chờ phản hồi tính bằng giây | + +## Hướng dẫn thiết lập + +1. Đăng nhập vào [Bảng điều khiển quản trị WeCom](https://work.weixin.qq.com/) +2. Vào "Quản lý ứng dụng" -> "Tạo ứng dụng" +3. Lấy ID doanh nghiệp (CorpID) và Secret của ứng dụng +4. Cấu hình "Nhận tin nhắn" trong cài đặt ứng dụng để lấy Token và EncodingAESKey +5. Đặt URL callback thành `http://:/webhook/wecom-app` +6. Nhập CorpID, Secret, AgentID và các thông tin khác vào file cấu hình + + Lưu ý: PicoClaw hiện sử dụng máy chủ HTTP Gateway dùng chung để nhận callback webhook cho tất cả các kênh. Địa chỉ lắng nghe mặc định là 127.0.0.1:18790. Để nhận callback từ internet công cộng, hãy cấu hình reverse proxy từ tên miền bên ngoài của bạn đến Gateway (cổng mặc định 18790). diff --git a/docs/channels/wecom/wecom_app/README.zh.md b/docs/channels/wecom/wecom_app/README.zh.md index 0a9858107..81268692d 100644 --- a/docs/channels/wecom/wecom_app/README.zh.md +++ b/docs/channels/wecom/wecom_app/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../../../README.zh.md) + # 企业微信自建应用 企业微信自建应用是指企业在企业微信中创建的应用,主要用于企业内部使用。通过企业微信自建应用,企业可以实现与员工的高效沟通和协作,提高工作效率。 diff --git a/docs/channels/wecom/wecom_bot/README.fr.md b/docs/channels/wecom/wecom_bot/README.fr.md new file mode 100644 index 000000000..fa3caeb37 --- /dev/null +++ b/docs/channels/wecom/wecom_bot/README.fr.md @@ -0,0 +1,41 @@ +> Retour au [README](../../../../README.fr.md) + +# WeCom Bot + +Le WeCom Bot est une méthode d'intégration rapide fournie par WeCom, permettant de recevoir des messages via une URL Webhook. + +## Configuration + +```json +{ + "channels": { + "wecom": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [], + "reply_timeout": 5 + } + } +} +``` + +| Champ | Type | Requis | Description | +| ---------------- | ------ | ------ | -------------------------------------------- | +| token | string | Oui | Jeton de vérification de signature | +| encoding_aes_key | string | Oui | Clé AES de 43 caractères utilisée pour le déchiffrement | +| webhook_url | string | Oui | URL Webhook du bot de groupe WeCom utilisée pour envoyer les réponses | +| webhook_path | string | Non | Chemin de l'endpoint webhook (par défaut : /webhook/wecom) | +| allow_from | array | Non | Liste blanche d'ID utilisateurs (vide = autoriser tous les utilisateurs) | +| reply_timeout | int | Non | Délai de réponse en secondes (par défaut : 5) | + +## Procédure de configuration + +1. Ajouter un bot à un groupe WeCom +2. Obtenir l'URL Webhook +3. (Pour recevoir des messages) Configurer l'adresse API de réception des messages (URL de callback), le Token et l'EncodingAESKey sur la page de configuration du bot +4. Saisir les informations pertinentes dans le fichier de configuration + + Remarque : PicoClaw utilise désormais un serveur HTTP Gateway partagé pour recevoir les callbacks webhook de tous les canaux. L'adresse d'écoute par défaut est 127.0.0.1:18790. Pour recevoir des callbacks depuis l'internet public, configurez un reverse proxy de votre domaine externe vers le Gateway (port par défaut 18790). diff --git a/docs/channels/wecom/wecom_bot/README.ja.md b/docs/channels/wecom/wecom_bot/README.ja.md new file mode 100644 index 000000000..c932c6b4f --- /dev/null +++ b/docs/channels/wecom/wecom_bot/README.ja.md @@ -0,0 +1,41 @@ +> [README](../../../../README.ja.md) に戻る + +# 企業WeChat ボット + +企業WeChat ボットは、企業WeChatが提供するWebhook URLを通じてメッセージを受信できる迅速な連携方式です。 + +## 設定 + +```json +{ + "channels": { + "wecom": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [], + "reply_timeout": 5 + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| ---------------- | ------ | ---- | -------------------------------------------- | +| token | string | はい | 署名検証トークン | +| encoding_aes_key | string | はい | 復号化に使用する43文字のAESキー | +| webhook_url | string | はい | 返信送信に使用する企業WeChatグループボットのWebhook URL | +| webhook_path | string | いいえ | Webhookエンドポイントパス(デフォルト:/webhook/wecom) | +| allow_from | array | いいえ | ユーザーIDの許可リスト(空 = 全ユーザーを許可) | +| reply_timeout | int | いいえ | 返信タイムアウト(秒、デフォルト:5) | + +## セットアップ手順 + +1. 企業WeChatグループにボットを追加 +2. Webhook URLを取得 +3. (メッセージを受信する場合)ボット設定ページでメッセージ受信APIアドレス(コールバックURL)、Token、EncodingAESKeyを設定 +4. 関連情報を設定ファイルに入力 + + 注意:PicoClawは現在、すべてのチャンネルのwebhookコールバックを受信するために共有のGateway HTTPサーバーを使用しています。デフォルトのリスニングアドレスは127.0.0.1:18790です。公共インターネットからコールバックを受信するには、外部ドメインをGateway(デフォルトポート18790)にリバースプロキシしてください。 diff --git a/docs/channels/wecom/wecom_bot/README.md b/docs/channels/wecom/wecom_bot/README.md new file mode 100644 index 000000000..2600a6a6b --- /dev/null +++ b/docs/channels/wecom/wecom_bot/README.md @@ -0,0 +1,41 @@ +> Back to [README](../../../../README.md) + +# WeCom Bot + +WeCom Bot is a quick integration method provided by WeCom that can receive messages via a Webhook URL. + +## Configuration + +```json +{ + "channels": { + "wecom": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [], + "reply_timeout": 5 + } + } +} +``` + +| Field | Type | Required | Description | +| ---------------- | ------ | -------- | -------------------------------------------- | +| token | string | Yes | Signature verification token | +| encoding_aes_key | string | Yes | 43-character AES key used for decryption | +| webhook_url | string | Yes | WeCom group bot webhook URL used to send replies | +| webhook_path | string | No | Webhook endpoint path (default: /webhook/wecom) | +| allow_from | array | No | User ID allowlist (empty = allow all users) | +| reply_timeout | int | No | Reply timeout in seconds (default: 5) | + +## Setup + +1. Add a bot to a WeCom group +2. Obtain the Webhook URL +3. (To receive messages) Configure the message receiving API address (callback URL), Token, and EncodingAESKey on the bot configuration page +4. Enter the relevant information into the config file + + Note: PicoClaw now uses a shared Gateway HTTP server to receive webhook callbacks for all channels. The default listening address is 127.0.0.1:18790. To receive callbacks from the public internet, reverse-proxy your external domain to the Gateway (default port 18790). diff --git a/docs/channels/wecom/wecom_bot/README.pt-br.md b/docs/channels/wecom/wecom_bot/README.pt-br.md new file mode 100644 index 000000000..4b3af1404 --- /dev/null +++ b/docs/channels/wecom/wecom_bot/README.pt-br.md @@ -0,0 +1,41 @@ +> Voltar ao [README](../../../../README.pt-br.md) + +# WeCom Bot + +O WeCom Bot é um método de integração rápida fornecido pelo WeCom que pode receber mensagens via URL de Webhook. + +## Configuração + +```json +{ + "channels": { + "wecom": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [], + "reply_timeout": 5 + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| ---------------- | ------ | ----------- | -------------------------------------------- | +| token | string | Sim | Token de verificação de assinatura | +| encoding_aes_key | string | Sim | Chave AES de 43 caracteres usada para descriptografia | +| webhook_url | string | Sim | URL do webhook do bot de grupo WeCom usada para enviar respostas | +| webhook_path | string | Não | Caminho do endpoint webhook (padrão: /webhook/wecom) | +| allow_from | array | Não | Lista de permissão de IDs de usuários (vazio = permitir todos) | +| reply_timeout | int | Não | Timeout de resposta em segundos (padrão: 5) | + +## Configuração passo a passo + +1. Adicione um bot a um grupo WeCom +2. Obtenha a URL do Webhook +3. (Para receber mensagens) Configure o endereço da API de recebimento de mensagens (URL de callback), Token e EncodingAESKey na página de configuração do bot +4. Insira as informações relevantes no arquivo de configuração + + Nota: O PicoClaw agora usa um servidor HTTP Gateway compartilhado para receber callbacks de webhook de todos os canais. O endereço de escuta padrão é 127.0.0.1:18790. Para receber callbacks da internet pública, configure um reverse proxy do seu domínio externo para o Gateway (porta padrão 18790). diff --git a/docs/channels/wecom/wecom_bot/README.vi.md b/docs/channels/wecom/wecom_bot/README.vi.md new file mode 100644 index 000000000..aab4b46cd --- /dev/null +++ b/docs/channels/wecom/wecom_bot/README.vi.md @@ -0,0 +1,41 @@ +> Quay lại [README](../../../../README.vi.md) + +# WeCom Bot + +WeCom Bot là phương thức tích hợp nhanh do WeCom cung cấp, có thể nhận tin nhắn qua URL Webhook. + +## Cấu hình + +```json +{ + "channels": { + "wecom": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [], + "reply_timeout": 5 + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| ---------------- | ------ | --------- | -------------------------------------------- | +| token | string | Có | Token xác minh chữ ký | +| encoding_aes_key | string | Có | Khóa AES 43 ký tự dùng để giải mã | +| webhook_url | string | Có | URL webhook của bot nhóm WeCom dùng để gửi phản hồi | +| webhook_path | string | Không | Đường dẫn endpoint webhook (mặc định: /webhook/wecom) | +| allow_from | array | Không | Danh sách cho phép ID người dùng (rỗng = cho phép tất cả) | +| reply_timeout | int | Không | Thời gian chờ phản hồi tính bằng giây (mặc định: 5) | + +## Hướng dẫn thiết lập + +1. Thêm bot vào một nhóm WeCom +2. Lấy URL Webhook +3. (Để nhận tin nhắn) Cấu hình địa chỉ API nhận tin nhắn (URL callback), Token và EncodingAESKey trên trang cấu hình bot +4. Nhập thông tin liên quan vào file cấu hình + + Lưu ý: PicoClaw hiện sử dụng máy chủ HTTP Gateway dùng chung để nhận callback webhook cho tất cả các kênh. Địa chỉ lắng nghe mặc định là 127.0.0.1:18790. Để nhận callback từ internet công cộng, hãy cấu hình reverse proxy từ tên miền bên ngoài của bạn đến Gateway (cổng mặc định 18790). diff --git a/docs/channels/wecom/wecom_bot/README.zh.md b/docs/channels/wecom/wecom_bot/README.zh.md index 63d9b84d6..016fcf973 100644 --- a/docs/channels/wecom/wecom_bot/README.zh.md +++ b/docs/channels/wecom/wecom_bot/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../../../README.zh.md) + # 企业微信机器人 企业微信机器人是企业微信提供的一种快速接入方式,可以通过 Webhook URL 接收消息。 diff --git a/docs/chat-apps.md b/docs/chat-apps.md index 05afc7f33..3ed37e814 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -8,22 +8,22 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, > **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server. -| Channel | Setup | -| ------------ | ---------------------------------- | -| **Telegram** | Easy (just a token) | -| **Discord** | Easy (bot token + intents) | -| **WhatsApp** | Easy (native: QR scan; or bridge URL) | -| **Matrix** | Medium (homeserver + bot access token) | -| **QQ** | Easy (AppID + AppSecret) | -| **DingTalk** | Medium (app credentials) | -| **LINE** | Medium (credentials + webhook URL) | -| **WeCom AI Bot** | Medium (Token + AES key) | -| **Feishu** | Medium (App ID + Secret, WebSocket mode) | -| **Slack** | Medium (Bot token + App token) | -| **IRC** | Medium (server + TLS config) | -| **OneBot** | Medium (QQ via OneBot protocol) | -| **MaixCam** | Easy (Sipeed hardware integration) | -| **Pico** | Native PicoClaw protocol | +| 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) | +| **WhatsApp** | ⭐ Easy | Native (QR scan) or Bridge URL | [Docs](#whatsapp) | +| **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) | +| **Pico** | ⭐ Easy | Native PicoClaw protocol channel | |
Telegram (Recommended) @@ -172,12 +172,13 @@ If `session_store_path` is empty, the session is stored in `/whatsapp
QQ -**1. Create a bot** +**Quick setup (recommended)** -- Go to [QQ Open Platform](https://q.qq.com/#) -- Create an application → Get **AppID** and **AppSecret** +QQ Open Platform provides a one-click setup page for OpenClaw-compatible bots: -**2. Configure** +1. Open [QQ Bot Quick Start](https://q.qq.com/qqbot/openclaw/index.html) and scan the QR code to log in +2. A bot is created automatically — copy the **App ID** and **App Secret** +3. Configure PicoClaw: ```json { @@ -192,13 +193,20 @@ If `session_store_path` is empty, the session is stored in `/whatsapp } ``` -> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access. +4. Run `picoclaw gateway` and open QQ to chat with your bot -**3. Run** +> The App Secret is only shown once. Save it immediately — viewing it again will force a reset. +> +> Bots created via the quick setup page are initially for the creator only and do not support group chats. To enable group access, configure sandbox mode on the [QQ Open Platform](https://q.qq.com/). -```bash -picoclaw gateway -``` +**Manual setup** + +If you prefer to create the bot manually: + +* Log in at [QQ Open Platform](https://q.qq.com/) to register as a developer +* Create a QQ bot — customize its avatar and name +* Copy the **App ID** and **App Secret** from the bot settings +* Configure as shown above and run `picoclaw gateway`
@@ -265,7 +273,7 @@ picoclaw gateway 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). +For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](channels/matrix/README.md).
@@ -326,7 +334,7 @@ PicoClaw supports three types of WeCom integration: **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. +See [WeCom AI Bot Configuration Guide](channels/wecom/wecom_aibot/README.md) for detailed setup instructions. **Quick Setup - WeCom Bot:** @@ -400,7 +408,7 @@ picoclaw gateway **1. Create an AI Bot** * Go to WeCom Admin Console → App Management → AI Bot -* In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot` +* In the AI Bot settings, configure callback URL: `http://your-server:18790/webhook/wecom-aibot` * Copy **Token** and click "Random Generate" for **EncodingAESKey** **2. Configure** @@ -414,7 +422,8 @@ picoclaw gateway "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], - "welcome_message": "Hello! How can I help you?" + "welcome_message": "Hello! How can I help you?", + "processing_message": "⏳ Processing, please wait. The results will be sent shortly." } } } @@ -429,3 +438,148 @@ 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. + +
+Feishu (Lark) + +PicoClaw connects to Feishu via WebSocket/SDK mode — no public webhook URL or callback server needed. + +**1. Create an app** + +* Go to [Feishu Open Platform](https://open.feishu.cn/) and create an application +* In the app settings, enable the **Bot** capability +* Create a version and publish the app (the app must be published to take effect) +* Copy the **App ID** (starts with `cli_`) and **App Secret** + +**2. Configure** + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "app_id": "cli_xxx", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +Optional fields: `encrypt_key` and `verification_token` for event encryption (recommended for production). + +**3. Run and chat** + +```bash +picoclaw gateway +``` + +Open Feishu, search for your bot name, and start chatting. You can also add the bot to a group — use `group_trigger.mention_only: true` to only respond when @mentioned. + +For full options, see [Feishu Channel Configuration Guide](channels/feishu/README.md). + +
+ +
+Slack + +**1. Create a Slack app** + +* Go to [Slack API](https://api.slack.com/apps) and create a new app +* Under **OAuth & Permissions**, add bot scopes: `chat:write`, `app_mentions:read`, `im:history`, `im:read`, `im:write` +* Install the app to your workspace +* Copy the **Bot Token** (`xoxb-...`) and **App-Level Token** (`xapp-...`, enable Socket Mode to get this) + +**2. Configure** + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "xoxb-YOUR-BOT-TOKEN", + "app_token": "xapp-YOUR-APP-TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Run** + +```bash +picoclaw gateway +``` + +
+ +
+IRC + +**1. Configure** + +```json +{ + "channels": { + "irc": { + "enabled": true, + "server": "irc.libera.chat:6697", + "tls": true, + "nick": "picoclaw-bot", + "channels": ["#your-channel"], + "password": "", + "allow_from": [] + } + } +} +``` + +Optional: `nickserv_password` for NickServ authentication, `sasl_user`/`sasl_password` for SASL auth. + +**2. Run** + +```bash +picoclaw gateway +``` + +The bot will connect to the IRC server and join the specified channels. + +
+ +
+OneBot (QQ via OneBot protocol) + +OneBot is an open protocol for QQ bots. PicoClaw connects to any OneBot v11 compatible implementation (e.g., [Lagrange](https://github.com/LagrangeDev/Lagrange.Core), [NapCat](https://github.com/NapNeko/NapCatQQ)) via WebSocket. + +**1. Set up a OneBot implementation** + +Install and run a OneBot v11 compatible QQ bot framework. Enable its WebSocket server. + +**2. Configure** + +```json +{ + "channels": { + "onebot": { + "enabled": true, + "ws_url": "ws://127.0.0.1:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| Field | Description | +|-------|-------------| +| `ws_url` | WebSocket URL of the OneBot implementation | +| `access_token` | Access token for authentication (if configured in OneBot) | +| `reconnect_interval` | Reconnect interval in seconds (default: 5) | + +**3. Run** + +```bash +picoclaw gateway +``` + +
diff --git a/docs/configuration.md b/docs/configuration.md index 202ad4f59..b5d652a85 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -57,7 +57,7 @@ By default, skills are loaded from: 1. `~/.picoclaw/workspace/skills` (workspace) 2. `~/.picoclaw/skills` (global) -3. `/skills` (builtin) +3. `/skills` (builtin, set at build time) For advanced/test setups, you can override the builtin skills root with: @@ -71,6 +71,135 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills - Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup. - Unknown slash command (for example `/foo`) passes through to normal LLM processing. - Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing. + +### Agent Bindings (Route messages to specific agents) + +Use `bindings` in `config.json` to route incoming messages to different agents by channel/account/context. + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-4o-mini" + }, + "list": [ + { "id": "main", "default": true, "name": "Main Assistant" }, + { "id": "support", "name": "Support Assistant" }, + { "id": "sales", "name": "Sales Assistant" } + ] + }, + "bindings": [ + { + "agent_id": "support", + "match": { + "channel": "telegram", + "account_id": "*", + "peer": { "kind": "direct", "id": "user123" } + } + }, + { + "agent_id": "sales", + "match": { + "channel": "discord", + "account_id": "my-discord-bot", + "guild_id": "987654321" + } + } + ] +} +``` + +#### `bindings` fields + +| Field | Required | Description | +|-------|----------|-------------| +| `agent_id` | Yes | Target agent id in `agents.list` | +| `match.channel` | Yes | Channel name (e.g. `telegram`, `discord`) | +| `match.account_id` | No | Channel account filter. Use `"*"` for all accounts of that channel. If omitted, only default account is matched | +| `match.peer.kind` + `match.peer.id` | No | Exact peer match (e.g. direct chat / topic / group id) | +| `match.guild_id` | No | Guild/server-level match | +| `match.team_id` | No | Team/workspace-level match | + +#### Matching priority + +When multiple bindings exist, PicoClaw resolves in this order: + +1. `peer` +2. `parent_peer` (for thread/topic parent contexts) +3. `guild_id` +4. `team_id` +5. `account_id` (non-wildcard) +6. channel wildcard (`account_id: "*"`) +7. default agent + +If a binding points to a missing `agent_id`, PicoClaw falls back to the default agent. + +#### How matching works (step-by-step) + +1. PicoClaw first filters bindings by `match.channel` (must equal current channel). +2. It then filters by `match.account_id`: + - omitted: match only the channel's default account + - `"*"`: match all accounts on this channel + - explicit value: exact account id match (case-insensitive) +3. From the remaining candidates, it applies the priority chain above and stops at the first hit. + +In other words: **channel + account form the candidate set; peer/guild/team then decide final winner**. + +#### Common recipes + +**1) Route one specific DM user to a specialist agent** + +```json +{ + "agent_id": "support", + "match": { + "channel": "telegram", + "account_id": "*", + "peer": { "kind": "direct", "id": "user123" } + } +} +``` + +**2) Route one Discord server (guild) to a dedicated agent** + +```json +{ + "agent_id": "sales", + "match": { + "channel": "discord", + "account_id": "my-discord-bot", + "guild_id": "987654321" + } +} +``` + +**3) Route all remaining traffic of a channel to a fallback agent** + +```json +{ + "agent_id": "main", + "match": { + "channel": "discord", + "account_id": "*" + } +} +``` + +#### Authoring guidelines (important) + +- Keep exactly one clear default agent in `agents.list` (`"default": true`). +- Put specific rules (`peer`, `guild_id`, `team_id`) and broad rules (`account_id: "*"` only) together safely; priority already guarantees specific rules win. +- Avoid duplicate rules with the same specificity and match values. If duplicates exist, the first matching entry in the config array wins. +- Ensure every `agent_id` exists in `agents.list`; unknown IDs silently fall back to default. + +#### Troubleshooting checklist + +- **Rule not taking effect?** Check `match.channel` spelling first (must be exact). +- **Expected account-specific routing but still using default?** Verify `match.account_id` equals actual runtime account id. +- **Wildcard catches too much traffic?** Add more specific `peer/guild/team` rules for critical paths. +- **Unexpected default fallback?** Confirm `agent_id` exists and is not misspelled. + ### 🔒 Security Sandbox PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. diff --git a/docs/credential_encryption.md b/docs/credential_encryption.md index 448eaaa10..dde8c782c 100644 --- a/docs/credential_encryption.md +++ b/docs/credential_encryption.md @@ -30,8 +30,9 @@ enc://AAAA...base64... "model_list": [ { "model_name": "gpt-4o", + "model": "openai/gpt-4o", "api_key": "enc://AAAA...base64...", - "base_url": "https://api.openai.com/v1" + "api_base": "https://api.openai.com/v1" } ] } @@ -54,20 +55,12 @@ enc://AAAA...base64... ### Key Derivation -Encryption uses **HKDF-SHA256** with an optional SSH private key as a second factor. +Encryption uses **HKDF-SHA256** with an SSH private key as a second factor. ``` -Without SSH key (passphrase only): - - ikm = SHA256(passphrase) - aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) - - -With SSH key (recommended): - - sshHash = SHA256(ssh_private_key_file_bytes) - ikm = HMAC-SHA256(key=sshHash, message=passphrase) - aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +sshHash = SHA256(ssh_private_key_file_bytes) +ikm = HMAC-SHA256(key=sshHash, message=passphrase) +aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) ``` ### Encryption @@ -125,7 +118,7 @@ This means a leaked config file alone is not sufficient to recover the API key, | Variable | Required | Description | |----------|----------|-------------| | `PICOCLAW_KEY_PASSPHRASE` | Yes (for `enc://`) | Passphrase used for key derivation | -| `PICOCLAW_SSH_KEY_PATH` | No | Path to SSH private key. Set to `""` to disable auto-detection and use passphrase-only mode | +| `PICOCLAW_SSH_KEY_PATH` | No | Path to SSH private key. If not set, auto-detects from `~/.ssh/picoclaw_ed25519.key` | ### SSH Key Auto-Detection @@ -140,11 +133,7 @@ Run `picoclaw onboard` to generate it automatically. `os.UserHomeDir()` is used for cross-platform home directory resolution (reads `USERPROFILE` on Windows, `HOME` on Unix/macOS). -To explicitly disable SSH key usage and use passphrase-only mode: - -```bash -export PICOCLAW_SSH_KEY_PATH="" -``` +> **Note:** An SSH key file is required for credential encryption. If no key is found and `PICOCLAW_SSH_KEY_PATH` is not set, encryption/decryption will fail. Run `picoclaw onboard` to generate the key automatically. --- @@ -162,7 +151,7 @@ No re-encryption is needed. ## Security Considerations -- **Passphrase strength matters in passphrase-only mode.** Without an SSH key, a weak passphrase can be brute-forced offline. Use `PICOCLAW_SSH_KEY_PATH=""` only in environments where no SSH key is available and the passphrase is sufficiently strong (≥ 32 random characters). +- **Both passphrase and SSH key are required.** The SSH key acts as a second factor — without it, encryption/decryption will fail. Run `picoclaw onboard` to generate the key if it doesn't exist. - **The SSH key is read-only at runtime.** PicoClaw never writes to or modifies the SSH key file. - **Plaintext keys remain supported.** Existing configs without `enc://` are unaffected. - **The `enc://` format is versioned** via the HKDF `info` field (`picoclaw-credential-v1`), allowing future algorithm upgrades without breaking existing encrypted values. diff --git a/docs/debug.md b/docs/debug.md index 7e28a15f2..b9e776f0f 100644 --- a/docs/debug.md +++ b/docs/debug.md @@ -31,3 +31,69 @@ When this flag is active, the global truncation function is disabled. This is ex * Verifying the exact syntax of the messages sent to the provider. * Reading the complete output of tools like `exec`, `web_fetch`, or `read_file`. * Debugging the session history saved in memory. + +## Tool Call Visibility in Debug Logs + +When debug mode is active, the agent emits structured log entries at each stage of the tool execution lifecycle. These entries carry a `component=agent` label and use `INFO` or `DEBUG` level depending on the amount of detail: + +| Log message | Level | Key fields | Description | +|---|---|---|---| +| `LLM requested tool calls` | INFO | `tools`, `count`, `iteration` | List of tool names the model decided to call | +| `Tool call: ()` | INFO | `tool`, `iteration` | The tool name and a preview of its arguments (truncated to 200 chars) | +| `Sent tool result to user` | DEBUG | `tool`, `content_len` | Fired when a tool result is forwarded to the chat channel | +| `TTL tick after tool execution` | DEBUG | `agent_id`, `iteration` | MCP tool-discovery TTL decrement after each tool round | +| `Async tool completed, publishing result` | INFO | `tool`, `content_len`, `channel` | Only for tools that run asynchronously in the background | + +### Reading a tool call log entry + +A typical synchronous tool call produces two consecutive lines in the console: + +``` +[...] [INFO] agent: LLM requested tool calls {tools=[web_search], count=1, iteration=1} +[...] [INFO] agent: Tool call: web_search({"query":"picoclaw release notes"}) {tool=web_search, iteration=1} +``` + +The arguments preview is hard-capped at **200 characters** in the logs regardless of the `--no-truncate` flag, because it belongs to the `INFO`-level path. Use `--no-truncate` together with `--debug` to see the full `tools_json` field emitted by the `Full LLM request` DEBUG entry, which contains every tool definition sent to the model. + +## Real-Time Tool Feedback in Chat (tool_feedback) + +Debug logs are server-side only. If you want the agent to send a visible notification directly into the chat channel every time it executes a tool—useful when sharing the bot with other users or for transparency—enable the `tool_feedback` feature in `config.json`: + +```json +{ + "agents": { + "defaults": { + "tool_feedback": { + "enabled": true, + "max_args_length": 300 + } + } + } +} +``` + +When `enabled` is `true`, every tool call sends a short message to the chat before the tool result is returned to the model. The message looks like: + +```bash +🔧 `web_search` +{"query": "picoclaw release notes"} +``` + + +### Options + +| Field | Type | Default | Description | +|---|---|---|---| +| `enabled` | bool | `false` | Send a chat notification for each tool call | +| `max_args_length` | int | `300` | Maximum characters of the serialised arguments included in the notification | + +### Environment variables + +Both fields can also be set via environment variables: + +```bash +PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED=true +PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH=300 +``` + +> **Note:** `tool_feedback` is independent of `--debug` mode. It works in production and does not require the gateway to be started with any special flag. diff --git a/docs/docker.md b/docs/docker.md index b91a7f68d..f868d4a42 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -12,6 +12,7 @@ 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 gateway up # The container prints "First-run setup complete." and stops. diff --git a/docs/fr/ANTIGRAVITY_AUTH.md b/docs/fr/ANTIGRAVITY_AUTH.md new file mode 100644 index 000000000..6cadf5238 --- /dev/null +++ b/docs/fr/ANTIGRAVITY_AUTH.md @@ -0,0 +1,809 @@ +> Retour au [README](../../README.fr.md) + +# Guide d'authentification et d'intégration Antigravity + +## Aperçu + +**Antigravity** (Google Cloud Code Assist) est un fournisseur de modèles IA soutenu par Google qui offre l'accès à des modèles tels que Claude Opus 4.6 et Gemini via l'infrastructure cloud de Google. Ce document fournit un guide complet sur le fonctionnement de l'authentification, la récupération des modèles et l'implémentation d'un nouveau fournisseur dans PicoClaw. + +--- + +## Table des matières + +1. [Flux d'authentification](#flux-dauthentification) +2. [Détails de l'implémentation OAuth](#détails-de-limplémentation-oauth) +3. [Gestion des jetons](#gestion-des-jetons) +4. [Récupération de la liste des modèles](#récupération-de-la-liste-des-modèles) +5. [Suivi de l'utilisation](#suivi-de-lutilisation) +6. [Structure du plugin fournisseur](#structure-du-plugin-fournisseur) +7. [Exigences d'intégration](#exigences-dintégration) +8. [Points de terminaison API](#points-de-terminaison-api) +9. [Configuration](#configuration) +10. [Créer un nouveau fournisseur dans PicoClaw](#créer-un-nouveau-fournisseur-dans-picoclaw) + +--- + +## Flux d'authentification + +### 1. OAuth 2.0 avec PKCE + +Antigravity utilise **OAuth 2.0 avec PKCE (Proof Key for Code Exchange)** pour une authentification sécurisée : + +``` +┌─────────────┐ ┌─────────────────┐ +│ Client │ ───(1) Generate PKCE Pair────────> │ │ +│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │ +│ │ │ Server │ +│ │ <──(3) Redirect with Code───────── │ │ +│ │ └─────────────────┘ +│ │ ───(4) Exchange Code for Tokens──> │ Token URL │ +│ │ │ │ +│ │ <──(5) Access + Refresh Tokens──── │ │ +└─────────────┘ └─────────────────┘ +``` + +### 2. Étapes détaillées + +#### Étape 1 : Générer les paramètres PKCE +```typescript +function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} +``` + +#### Étape 2 : Construire l'URL d'autorisation +```typescript +const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const REDIRECT_URI = "http://localhost:51121/oauth-callback"; + +function buildAuthUrl(params: { challenge: string; state: string }): string { + const url = new URL(AUTH_URL); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", SCOPES.join(" ")); + url.searchParams.set("code_challenge", params.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", params.state); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + return url.toString(); +} +``` + +**Portées requises :** +```typescript +const SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", +]; +``` + +#### Étape 3 : Gérer le callback OAuth + +**Mode automatique (développement local) :** +- Démarrer un serveur HTTP local sur le port 51121 +- Attendre la redirection de Google +- Extraire le code d'autorisation des paramètres de requête + +**Mode manuel (distant/sans interface graphique) :** +- Afficher l'URL d'autorisation à l'utilisateur +- L'utilisateur complète l'authentification dans son navigateur +- L'utilisateur colle l'URL de redirection complète dans le terminal +- Analyser le code depuis l'URL collée + +#### Étape 4 : Échanger le code contre des jetons +```typescript +const TOKEN_URL = "https://oauth2.googleapis.com/token"; + +async function exchangeCode(params: { + code: string; + verifier: string; +}): Promise<{ access: string; refresh: string; expires: number }> { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: params.code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + code_verifier: params.verifier, + }), + }); + + const data = await response.json(); + + return { + access: data.access_token, + refresh: data.refresh_token, + expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer + }; +} +``` + +#### Étape 5 : Récupérer les données utilisateur supplémentaires + +**E-mail de l'utilisateur :** +```typescript +async function fetchUserEmail(accessToken: string): Promise { + const response = await fetch( + "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + const data = await response.json(); + return data.email; +} +``` + +**ID du projet (requis pour les appels API) :** +```typescript +async function fetchProjectId(accessToken: string): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": JSON.stringify({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }), + }; + + const response = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers, + body: JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + const data = await response.json(); + return data.cloudaicompanionProject || "rising-fact-p41fc"; // Valeur par défaut +} +``` + +--- + +## Détails de l'implémentation OAuth + +### Identifiants client + +**Important :** Ceux-ci sont encodés en base64 dans le code source pour la synchronisation avec pi-ai : + +```typescript +const decode = (s: string) => Buffer.from(s, "base64").toString(); + +const CLIENT_ID = decode( + "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==" +); +const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); +``` + +### Modes de flux OAuth + +1. **Flux automatique** (machines locales avec navigateur) : + - Ouvre le navigateur automatiquement + - Le serveur de callback local capture la redirection + - Aucune interaction utilisateur requise après l'authentification initiale + +2. **Flux manuel** (distant/sans interface/WSL2) : + - URL affichée pour copier-coller manuellement + - L'utilisateur complète l'authentification dans un navigateur externe + - L'utilisateur colle l'URL de redirection complète + +```typescript +function shouldUseManualOAuthFlow(isRemote: boolean): boolean { + return isRemote || isWSL2Sync(); +} +``` + +--- + +## Gestion des jetons + +### Structure du profil d'authentification + +```typescript +type OAuthCredential = { + type: "oauth"; + provider: "google-antigravity"; + access: string; // Jeton d'accès + refresh: string; // Jeton de rafraîchissement + expires: number; // Horodatage d'expiration (ms depuis epoch) + email?: string; // E-mail de l'utilisateur + projectId?: string; // ID du projet Google Cloud +}; +``` + +### Rafraîchissement des jetons + +Les identifiants incluent un jeton de rafraîchissement qui peut être utilisé pour obtenir de nouveaux jetons d'accès lorsque le jeton actuel expire. L'expiration est définie avec un tampon de 5 minutes pour éviter les conditions de concurrence. + +--- + +## Récupération de la liste des modèles + +### Récupérer les modèles disponibles + +```typescript +const BASE_URL = "https://cloudcode-pa.googleapis.com"; + +async function fetchAvailableModels( + accessToken: string, + projectId: string +): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + }; + + const response = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers, + body: JSON.stringify({ project: projectId }), + } + ); + + const data = await response.json(); + + // Retourne les modèles avec les informations de quota + return Object.entries(data.models).map(([modelId, modelInfo]) => ({ + id: modelId, + displayName: modelInfo.displayName, + quotaInfo: { + remainingFraction: modelInfo.quotaInfo?.remainingFraction, + resetTime: modelInfo.quotaInfo?.resetTime, + isExhausted: modelInfo.quotaInfo?.isExhausted, + }, + })); +} +``` + +### Format de réponse + +```typescript +type FetchAvailableModelsResponse = { + models?: Record; +}; +``` + +--- + +## Suivi de l'utilisation + +### Récupérer les données d'utilisation + +```typescript +export async function fetchAntigravityUsage( + token: string, + timeoutMs: number +): Promise { + // 1. Récupérer les crédits et les informations du plan + const loadCodeAssistRes = await fetch( + `${BASE_URL}/v1internal:loadCodeAssist`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: { + ideType: "ANTIGRAVITY", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + // Extraire les informations de crédits + const { availablePromptCredits, planInfo, currentTier } = data; + + // 2. Récupérer les quotas des modèles + const modelsRes = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ project: projectId }), + } + ); + + // Construire les fenêtres d'utilisation + return { + provider: "google-antigravity", + displayName: "Google Antigravity", + windows: [ + { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) }, + // Quotas individuels des modèles... + ], + plan: currentTier?.name || planType, + }; +} +``` + +### Structure de la réponse d'utilisation + +```typescript +type ProviderUsageSnapshot = { + provider: "google-antigravity"; + displayName: string; + windows: UsageWindow[]; + plan?: string; + error?: string; +}; + +type UsageWindow = { + label: string; // "Credits" ou ID du modèle + usedPercent: number; // 0-100 + resetAt?: number; // Horodatage de réinitialisation du quota +}; +``` + +--- + +## Structure du plugin fournisseur + +### Définition du plugin + +```typescript +const antigravityPlugin = { + id: "google-antigravity-auth", + name: "Google Antigravity Auth", + description: "OAuth flow for Google Antigravity (Cloud Code Assist)", + configSchema: emptyPluginConfigSchema(), + + register(api: PicoClawPluginApi) { + api.registerProvider({ + id: "google-antigravity", + label: "Google Antigravity", + docsPath: "/providers/models", + aliases: ["antigravity"], + + auth: [ + { + id: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + kind: "oauth", + run: async (ctx: ProviderAuthContext) => { + // Implémentation OAuth ici + }, + }, + ], + }); + }, +}; +``` + +### ProviderAuthContext + +```typescript +type ProviderAuthContext = { + config: PicoClawConfig; + agentDir?: string; + workspaceDir?: string; + prompter: WizardPrompter; // Invites/notifications UI + runtime: RuntimeEnv; // Journalisation, etc. + isRemote: boolean; // Exécution à distance ou non + openUrl: (url: string) => Promise; // Ouverture du navigateur + oauth: { + createVpsAwareHandlers: Function; + }; +}; +``` + +### ProviderAuthResult + +```typescript +type ProviderAuthResult = { + profiles: Array<{ + profileId: string; + credential: AuthProfileCredential; + }>; + configPatch?: Partial; + defaultModel?: string; + notes?: string[]; +}; +``` + +--- + +## Exigences d'intégration + +### 1. Environnement/dépendances requis + +- Go ≥ 1.25 +- Base de code PicoClaw (`pkg/providers/` et `pkg/auth/`) +- Packages de la bibliothèque standard `crypto` et `net/http` + +### 2. En-têtes requis pour les appels API + +```typescript +const REQUIRED_HEADERS = { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", // ou "google-api-nodejs-client/9.15.1" + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", +}; + +// Pour les appels loadCodeAssist, inclure également : +const CLIENT_METADATA = { + ideType: "ANTIGRAVITY", // ou "IDE_UNSPECIFIED" + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", +}; +``` + +### 3. Assainissement des schémas de modèles + +Antigravity utilise des modèles compatibles Gemini, les schémas d'outils doivent donc être assainis : + +```typescript +const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([ + "patternProperties", + "additionalProperties", + "$schema", + "$id", + "$ref", + "$defs", + "definitions", + "examples", + "minLength", + "maxLength", + "minimum", + "maximum", + "multipleOf", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems", + "minProperties", + "maxProperties", +]); + +// Nettoyer le schéma avant l'envoi +function cleanToolSchemaForGemini(schema: Record): unknown { + // Supprimer les mots-clés non supportés + // S'assurer que le niveau supérieur a type: "object" + // Aplatir les unions anyOf/oneOf +} +``` + +### 4. Gestion des blocs de réflexion (modèles Claude) + +Pour les modèles Claude via Antigravity, les blocs de réflexion nécessitent un traitement spécial : + +```typescript +const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +export function sanitizeAntigravityThinkingBlocks( + messages: AgentMessage[] +): AgentMessage[] { + // Valider les signatures de réflexion + // Normaliser les champs de signature + // Rejeter les blocs de réflexion non signés +} +``` + +--- + +## Points de terminaison API + +### Points de terminaison d'authentification + +| Point de terminaison | Méthode | Objectif | +|---------------------|---------|----------| +| `https://accounts.google.com/o/oauth2/v2/auth` | GET | Autorisation OAuth | +| `https://oauth2.googleapis.com/token` | POST | Échange de jetons | +| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | Informations utilisateur (e-mail) | + +### Points de terminaison Cloud Code Assist + +| Point de terminaison | Méthode | Objectif | +|---------------------|---------|----------| +| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | Charger les infos du projet, crédits, plan | +| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | Lister les modèles disponibles avec quotas | +| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | Point de terminaison de streaming de chat | + +**Format de requête API (chat) :** +Le point de terminaison `v1internal:streamGenerateContent` attend une enveloppe encapsulant la requête Gemini standard : + +```json +{ + "project": "your-project-id", + "model": "model-id", + "request": { + "contents": [...], + "systemInstruction": {...}, + "generationConfig": {...}, + "tools": [...] + }, + "requestType": "agent", + "userAgent": "antigravity", + "requestId": "agent-timestamp-random" +} +``` + +**Format de réponse API (SSE) :** +Chaque message SSE (`data: {...}`) est encapsulé dans un champ `response` : + +```json +{ + "response": { + "candidates": [...], + "usageMetadata": {...}, + "modelVersion": "...", + "responseId": "..." + }, + "traceId": "...", + "metadata": {} +} +``` + +--- + +## Configuration + +### Configuration config.json + +```json +{ + "model_list": [ + { + "model_name": "gemini-flash", + "model": "antigravity/gemini-3-flash", + "auth_method": "oauth" + } + ], + "agents": { + "defaults": { + "model_name": "gemini-flash" + } + } +} +``` + +### Stockage du profil d'authentification + +Les profils d'authentification sont stockés dans `~/.picoclaw/auth.json` : + +```json +{ + "credentials": { + "google-antigravity": { + "access_token": "ya29...", + "refresh_token": "1//...", + "expires_at": "2026-01-01T00:00:00Z", + "provider": "google-antigravity", + "auth_method": "oauth", + "email": "user@example.com", + "project_id": "my-project-id" + } + } +} +``` + +--- + +## Créer un nouveau fournisseur dans PicoClaw + +Les fournisseurs PicoClaw sont implémentés en tant que packages Go sous `pkg/providers/`. Pour ajouter un nouveau fournisseur : + +### Implémentation étape par étape + +#### 1. Créer le fichier du fournisseur + +Créez un nouveau fichier Go dans `pkg/providers/` : + +``` +pkg/providers/ +└── your_provider.go +``` + +#### 2. Implémenter l'interface Provider + +Votre fournisseur doit implémenter l'interface `Provider` définie dans `pkg/providers/types.go` : + +```go +package providers + +type YourProvider struct { + apiKey string + apiBase string +} + +func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider { + if apiBase == "" { + apiBase = "https://api.your-provider.com/v1" + } + return &YourProvider{apiKey: apiKey, apiBase: apiBase} +} + +func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error { + // Implémenter la complétion de chat avec streaming +} +``` + +#### 3. Enregistrer dans la factory + +Ajoutez votre fournisseur au switch de protocole dans `pkg/providers/factory.go` : + +```go +case "your-provider": + return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil +``` + +#### 4. Ajouter la configuration par défaut (optionnel) + +Ajoutez une entrée par défaut dans `pkg/config/defaults.go` : + +```go +{ + ModelName: "your-model", + Model: "your-provider/model-name", + APIKey: "", +}, +``` + +#### 5. Ajouter le support d'authentification (optionnel) + +Si votre fournisseur nécessite OAuth ou une authentification spéciale, ajoutez un cas dans `cmd/picoclaw/internal/auth/helpers.go` : + +```go +case "your-provider": + authLoginYourProvider() +``` + +#### 6. Configurer via `config.json` + +```json +{ + "model_list": [ + { + "model_name": "your-model", + "model": "your-provider/model-name", + "api_key": "your-api-key", + "api_base": "https://api.your-provider.com/v1" + } + ] +} +``` + +--- + +## Tester votre implémentation + +### Commandes CLI + +```bash +# S'authentifier avec un fournisseur +picoclaw auth login --provider your-provider + +# Lister les modèles (pour Antigravity) +picoclaw auth models + +# Démarrer la passerelle +picoclaw gateway + +# Exécuter un agent avec un modèle spécifique +picoclaw agent -m "Hello" --model your-model +``` + +### Variables d'environnement pour les tests + +```bash +# Remplacer le modèle par défaut +export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model + +# Remplacer les paramètres du fournisseur +export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]' +``` + +--- + +## Références + +- **Fichiers source :** + - `pkg/providers/antigravity_provider.go` - Implémentation du fournisseur Antigravity + - `pkg/auth/oauth.go` - Implémentation du flux OAuth + - `pkg/auth/store.go` - Stockage des identifiants d'authentification (`~/.picoclaw/auth.json`) + - `pkg/providers/factory.go` - Factory des fournisseurs et routage de protocole + - `pkg/providers/types.go` - Définitions de l'interface fournisseur + - `cmd/picoclaw/internal/auth/helpers.go` - Commandes CLI d'authentification + +- **Documentation :** + - `docs/ANTIGRAVITY_USAGE.md` - Guide d'utilisation d'Antigravity + - `docs/migration/model-list-migration.md` - Guide de migration + +--- + +## Notes + +1. **Projet Google Cloud :** Antigravity nécessite que Gemini for Google Cloud soit activé sur votre projet Google Cloud +2. **Quotas :** Utilise les quotas du projet Google Cloud (pas de facturation séparée) +3. **Accès aux modèles :** Les modèles disponibles dépendent de la configuration de votre projet Google Cloud +4. **Blocs de réflexion :** Les modèles Claude via Antigravity nécessitent un traitement spécial des blocs de réflexion avec signatures +5. **Assainissement des schémas :** Les schémas d'outils doivent être assainis pour supprimer les mots-clés JSON Schema non supportés + +--- + +--- + +## Gestion des erreurs courantes + +### 1. Limitation de débit (HTTP 429) + +Antigravity retourne une erreur 429 lorsque les quotas du projet/modèle sont épuisés. La réponse d'erreur contient souvent un `quotaResetDelay` dans le champ `details`. + +**Exemple d'erreur 429 :** +```json +{ + "error": { + "code": 429, + "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.", + "status": "RESOURCE_EXHAUSTED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "metadata": { + "quotaResetDelay": "4h30m28.060903746s" + } + } + ] + } +} +``` + +### 2. Réponses vides (modèles restreints) + +Certains modèles peuvent apparaître dans la liste des modèles disponibles mais retourner une réponse vide (200 OK mais flux SSE vide). Cela se produit généralement pour les modèles en préversion ou restreints que le projet actuel n'a pas la permission d'utiliser. + +**Traitement :** Traiter les réponses vides comme des erreurs informant l'utilisateur que le modèle pourrait être restreint ou invalide pour son projet. + +--- + +## Dépannage + +### "Token expired" (jeton expiré) +- Rafraîchir les jetons OAuth : `picoclaw auth login --provider antigravity` + +### "Gemini for Google Cloud is not enabled" (Gemini for Google Cloud n'est pas activé) +- Activer l'API dans votre Google Cloud Console + +### "Project not found" (projet non trouvé) +- Vérifier que votre projet Google Cloud a les API nécessaires activées +- Vérifier que l'ID du projet est correctement récupéré lors de l'authentification + +### Les modèles n'apparaissent pas dans la liste +- Vérifier que l'authentification OAuth s'est terminée avec succès +- Vérifier le stockage du profil d'authentification : `~/.picoclaw/auth.json` +- Relancer `picoclaw auth login --provider antigravity` diff --git a/docs/fr/ANTIGRAVITY_USAGE.md b/docs/fr/ANTIGRAVITY_USAGE.md new file mode 100644 index 000000000..d6d0a2bd4 --- /dev/null +++ b/docs/fr/ANTIGRAVITY_USAGE.md @@ -0,0 +1,72 @@ +> Retour au [README](../../README.fr.md) + +# Utiliser le fournisseur Antigravity dans PicoClaw + +Ce guide explique comment configurer et utiliser le fournisseur **Antigravity** (Google Cloud Code Assist) dans PicoClaw. + +## Prérequis + +1. Un compte Google. +2. Google Cloud Code Assist activé (généralement disponible via l'intégration « Gemini for Google Cloud »). + +## 1. Authentification + +Pour vous authentifier avec Antigravity, exécutez la commande suivante : + +```bash +picoclaw auth login --provider antigravity +``` + +### Authentification manuelle (Headless/VPS) +Si vous exécutez PicoClaw sur un serveur (Coolify/Docker) et ne pouvez pas accéder à `localhost`, suivez ces étapes : +1. Exécutez la commande ci-dessus. +2. Copiez l'URL fournie et ouvrez-la dans votre navigateur local. +3. Complétez la connexion. +4. Votre navigateur sera redirigé vers une URL `localhost:51121` (qui ne se chargera pas). +5. **Copiez cette URL finale** depuis la barre d'adresse de votre navigateur. +6. **Collez-la dans le terminal** où PicoClaw attend. + +PicoClaw extraira automatiquement le code d'autorisation et terminera le processus. + +## 2. Gestion des modèles + +### Lister les modèles disponibles +Pour voir quels modèles sont accessibles à votre projet et vérifier leurs quotas : + +```bash +picoclaw auth models +``` + +### Changer de modèle +Vous pouvez modifier le modèle par défaut dans `~/.picoclaw/config.json` ou le remplacer via le CLI : + +```bash +# Remplacer pour une seule commande +picoclaw agent -m "Hello" --model claude-opus-4-6-thinking +``` + +## 3. Utilisation en production (Coolify/Docker) + +Si vous déployez via Coolify ou Docker, suivez ces étapes pour tester : + +1. **Variables d'environnement** : + * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash` +2. **Persistance de l'authentification** : + Si vous vous êtes connecté localement, vous pouvez copier vos identifiants vers le serveur : + ```bash + scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/ + ``` + *Alternativement*, exécutez la commande `auth login` une fois sur le serveur si vous avez un accès terminal. + +## 4. Dépannage + +* **Réponse vide** : Si un modèle renvoie une réponse vide, il peut être restreint pour votre projet. Essayez `gemini-3-flash` ou `claude-opus-4-6-thinking`. +* **429 Limite de débit** : Antigravity a des quotas stricts. PicoClaw affichera le « temps de réinitialisation » dans le message d'erreur si vous atteignez une limite. +* **404 Non trouvé** : Assurez-vous d'utiliser un ID de modèle provenant de la liste `picoclaw auth models`. Utilisez l'ID court (par ex. `gemini-3-flash`) et non le chemin complet. + +## 5. Résumé des modèles fonctionnels + +D'après les tests, les modèles suivants sont les plus fiables : +* `gemini-3-flash` (Rapide, haute disponibilité) +* `gemini-2.5-flash-lite` (Léger) +* `claude-opus-4-6-thinking` (Puissant, inclut le raisonnement) diff --git a/docs/fr/chat-apps.md b/docs/fr/chat-apps.md index 03bb6e17b..67422e0ec 100644 --- a/docs/fr/chat-apps.md +++ b/docs/fr/chat-apps.md @@ -8,22 +8,22 @@ Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, Din > **Note** : Tous les canaux basés sur les webhooks (LINE, WeCom, etc.) sont servis sur un seul serveur HTTP Gateway partagé (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Il n'y a pas de ports par canal à configurer. Note : Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP webhook partagé. -| Canal | Configuration | -| ------------ | -------------------------------------- | -| **Telegram** | Facile (juste un token) | -| **Discord** | Facile (bot token + intents) | -| **WhatsApp** | Facile (natif : scan QR ; ou bridge URL) | -| **Matrix** | Moyen (homeserver + bot access token) | -| **QQ** | Facile (AppID + AppSecret) | -| **DingTalk** | Moyen (identifiants de l'application) | -| **LINE** | Moyen (identifiants + webhook URL) | -| **WeCom AI Bot** | Moyen (Token + clé AES) | -| **Feishu** | Moyen (App ID + Secret, mode WebSocket) | -| **Slack** | Moyen (Bot token + App token) | -| **IRC** | Moyen (serveur + configuration TLS) | -| **OneBot** | Moyen (QQ via protocole OneBot) | -| **MaixCam** | Facile (intégration matérielle Sipeed) | -| **Pico** | Native PicoClaw protocol | +| Canal | Difficulté | Description | Documentation | +| -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| **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) | +| **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) | +| **DingTalk** | ⭐⭐ Moyen | Mode Stream (pas d'IP publique requise), entreprise | [Documentation](../channels/dingtalk/README.fr.md) | +| **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 | - | +| **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é) @@ -168,12 +168,13 @@ Si `session_store_path` est vide, la session est stockée dans `/what
QQ -**1. Créer un bot** +**Configuration rapide (recommandée)** -- Allez sur [QQ Open Platform](https://q.qq.com/#) -- Créez une application → Obtenez **AppID** et **AppSecret** +QQ Open Platform propose une page de configuration en un clic pour les bots compatibles OpenClaw : -**2. Configurer** +1. Ouvrez [QQ Bot Quick Start](https://q.qq.com/qqbot/openclaw/index.html) et scannez le QR code pour vous connecter +2. Un bot est créé automatiquement — copiez l'**App ID** et l'**App Secret** +3. Configurez PicoClaw : ```json { @@ -188,13 +189,20 @@ Si `session_store_path` est vide, la session est stockée dans `/what } ``` -> Définissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des numéros QQ pour restreindre l'accès. +4. Lancez `picoclaw gateway` et ouvrez QQ pour discuter avec votre bot -**3. Lancer** +> L'App Secret n'est affiché qu'une seule fois. Enregistrez-le immédiatement — le consulter à nouveau forcera une réinitialisation. +> +> Les bots créés via la page de configuration rapide sont initialement réservés au créateur et ne prennent pas en charge les discussions de groupe. Pour activer l'accès en groupe, configurez le mode sandbox sur la [QQ Open Platform](https://q.qq.com/). -```bash -picoclaw gateway -``` +**Configuration manuelle** + +Si vous préférez créer le bot manuellement : + +* Connectez-vous sur [QQ Open Platform](https://q.qq.com/) pour vous inscrire en tant que développeur +* Créez un bot QQ — personnalisez son avatar et son nom +* Copiez l'**App ID** et l'**App Secret** depuis les paramètres du bot +* Configurez comme indiqué ci-dessus et lancez `picoclaw gateway`
@@ -261,7 +269,7 @@ picoclaw gateway picoclaw gateway ``` -Pour toutes les options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), voir le [Guide de Configuration du Canal Matrix](docs/channels/matrix/README.md). +Pour toutes les options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), voir le [Guide de Configuration du Canal Matrix](../channels/matrix/README.md).
@@ -322,7 +330,7 @@ PicoClaw prend en charge trois types d'intégration WeCom : **Option 2 : WeCom App (Application personnalisée)** - Plus de fonctionnalités, messagerie proactive, chat privé uniquement **Option 3 : WeCom AI Bot (Bot IA)** - Bot IA officiel, réponses en streaming, prend en charge les discussions de groupe et privées -Voir le [Guide de Configuration WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) pour les instructions détaillées. +Voir le [Guide de Configuration WeCom AI Bot](../channels/wecom/wecom_aibot/README.fr.md) pour les instructions détaillées. **Configuration rapide - WeCom Bot :** @@ -396,7 +404,7 @@ picoclaw gateway **1. Créer un AI Bot** * Allez dans la console d'administration WeCom → Gestion des applications → AI Bot -* Dans les paramètres du AI Bot, configurez l'URL de callback : `http://your-server:18791/webhook/wecom-aibot` +* Dans les paramètres du AI Bot, configurez l'URL de callback : `http://your-server:18790/webhook/wecom-aibot` * Copiez **Token** et cliquez sur "Générer aléatoirement" pour **EncodingAESKey** **2. Configurer** @@ -410,7 +418,8 @@ picoclaw gateway "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], - "welcome_message": "Hello! How can I help you?" + "welcome_message": "Hello! How can I help you?", + "processing_message": "⏳ Processing, please wait. The results will be sent shortly." } } } @@ -429,10 +438,14 @@ picoclaw gateway
Feishu (飞书) +PicoClaw se connecte à Feishu via le mode WebSocket/SDK — aucune URL webhook publique ni serveur de callback nécessaire. + **1. Créer une application** -* Allez sur [Feishu Open Platform](https://open.feishu.cn/) -* Créez une application → Obtenez **App ID** et **App Secret** +* Allez sur [Feishu Open Platform](https://open.feishu.cn/) et créez une application +* Dans les paramètres de l'application, activez la capacité **Bot** +* Créez une version et publiez l'application (l'application doit être publiée pour prendre effet) +* Copiez l'**App ID** (commence par `cli_`) et l'**App Secret** **2. Configurer** @@ -442,23 +455,25 @@ picoclaw gateway "feishu": { "enabled": true, "app_id": "cli_xxx", - "app_secret": "xxx", - "encrypt_key": "", - "verification_token": "", + "app_secret": "YOUR_APP_SECRET", "allow_from": [] } } } ``` -> Feishu utilise le mode WebSocket/SDK et ne nécessite pas de serveur webhook. +Optionnel : `encrypt_key` et `verification_token` pour le chiffrement des événements (recommandé en production). -**3. Lancer** +**3. Lancer et discuter** ```bash picoclaw gateway ``` +Ouvrez Feishu, recherchez le nom de votre bot et commencez à discuter. Vous pouvez aussi ajouter le bot à un groupe — utilisez `group_trigger.mention_only: true` pour ne répondre que lorsqu'il est @mentionné. + +Pour toutes les options, voir le [Guide de Configuration du Canal Feishu](../channels/feishu/README.fr.md). +
@@ -466,9 +481,10 @@ picoclaw gateway **1. Créer une application Slack** -* Allez sur [Slack API](https://api.slack.com/apps) -* Créez une nouvelle application -* Obtenez le **Bot Token** et l'**App Token** +* Allez sur [Slack API](https://api.slack.com/apps) et créez une nouvelle application +* Sous **OAuth & Permissions**, ajoutez les scopes bot : `chat:write`, `app_mentions:read`, `im:history`, `im:read`, `im:write` +* Installez l'application dans votre workspace +* Copiez le **Bot Token** (`xoxb-...`) et l'**App-Level Token** (`xapp-...`, activez Socket Mode pour l'obtenir) **2. Configurer** @@ -477,8 +493,8 @@ picoclaw gateway "channels": { "slack": { "enabled": true, - "bot_token": "xoxb-your-bot-token", - "app_token": "xapp-your-app-token", + "bot_token": "xoxb-YOUR-BOT-TOKEN", + "app_token": "xapp-YOUR-APP-TOKEN", "allow_from": [] } } @@ -496,42 +512,44 @@ picoclaw gateway
IRC -**1. Configurer le serveur IRC** - -* Préparez les informations de votre serveur IRC (adresse, port, canal) - -**2. Configurer** +**1. Configurer** ```json { "channels": { "irc": { "enabled": true, - "server": "irc.example.com:6697", + "server": "irc.libera.chat:6697", + "tls": true, "nick": "picoclaw-bot", - "channel": "#your-channel", - "use_tls": true, + "channels": ["#your-channel"], + "password": "", "allow_from": [] } } } ``` -**3. Lancer** +Optionnel : `nickserv_password` pour l'authentification NickServ, `sasl_user`/`sasl_password` pour l'authentification SASL. + +**2. Lancer** ```bash picoclaw gateway ``` +Le bot se connectera au serveur IRC et rejoindra les canaux spécifiés. +
-OneBot +OneBot (QQ via protocole OneBot) -**1. Configurer OneBot** +OneBot est un protocole ouvert pour les bots QQ. PicoClaw se connecte à toute implémentation compatible OneBot v11 (par ex. [Lagrange](https://github.com/LagrangeDev/Lagrange.Core), [NapCat](https://github.com/NapNeko/NapCatQQ)) via WebSocket. -* Installez une implémentation OneBot compatible (par ex. go-cqhttp, Lagrange) -* Configurez la connexion WebSocket +**1. Configurer une implémentation OneBot** + +Installez et exécutez un framework de bot QQ compatible OneBot v11. Activez son serveur WebSocket. **2. Configurer** @@ -540,14 +558,19 @@ picoclaw gateway "channels": { "onebot": { "enabled": true, - "ws_url": "ws://localhost:8080", + "ws_url": "ws://127.0.0.1:8080", + "access_token": "", "allow_from": [] } } } ``` -> OneBot permet d'utiliser QQ via le protocole OneBot standard. +| Champ | Description | +|-------|-------------| +| `ws_url` | URL WebSocket de l'implémentation OneBot | +| `access_token` | Token d'accès pour l'authentification (si configuré dans OneBot) | +| `reconnect_interval` | Intervalle de reconnexion en secondes (par défaut : 5) | **3. Lancer** diff --git a/docs/fr/configuration.md b/docs/fr/configuration.md index ef02acf8a..d56da2cad 100644 --- a/docs/fr/configuration.md +++ b/docs/fr/configuration.md @@ -56,7 +56,7 @@ Par défaut, les compétences sont chargées depuis : 1. `~/.picoclaw/workspace/skills` (workspace) 2. `~/.picoclaw/skills` (global) -3. `/skills` (builtin) +3. `/skills` (intégré) Pour les configurations avancées/de test, vous pouvez remplacer la racine des compétences builtin avec : diff --git a/docs/fr/credential_encryption.md b/docs/fr/credential_encryption.md new file mode 100644 index 000000000..eec765039 --- /dev/null +++ b/docs/fr/credential_encryption.md @@ -0,0 +1,159 @@ +> Retour au [README](../../README.fr.md) + +# Chiffrement des identifiants + +PicoClaw prend en charge le chiffrement des valeurs `api_key` dans les entrées de configuration `model_list`. +Les clés chiffrées sont stockées sous forme de chaînes `enc://` et déchiffrées automatiquement au démarrage. + +--- + +## Démarrage rapide + +**1. Définir votre phrase secrète** + +```bash +export PICOCLAW_KEY_PASSPHRASE="your-passphrase" +``` + +**2. Chiffrer une clé API** + +Exécutez `picoclaw onboard` — il vous demande votre phrase secrète et génère la clé SSH, +puis re-chiffre automatiquement toutes les entrées `api_key` en clair dans votre configuration +lors du prochain appel à `SaveConfig`. La valeur `enc://` résultante ressemblera à : + +``` +enc://AAAA...base64... +``` + +**3. Coller la sortie dans votre configuration** + +```json +{ + "model_list": [ + { + "model_name": "gpt-4o", + "model": "openai/gpt-4o", + "api_key": "enc://AAAA...base64...", + "api_base": "https://api.openai.com/v1" + } + ] +} +``` + +--- + +## Formats `api_key` pris en charge + +| Format | Exemple | Comportement | +|--------|---------|--------------| +| Texte clair | `sk-abc123` | Utilisé tel quel | +| Référence fichier | `file://openai.key` | Contenu lu depuis le même répertoire que le fichier de configuration | +| Chiffré | `enc://` | Déchiffré au démarrage avec `PICOCLAW_KEY_PASSPHRASE` | +| Vide | `""` | Transmis tel quel (utilisé avec `auth_method: oauth`) | + +--- + +## Conception cryptographique + +### Dérivation de clé + +Le chiffrement utilise **HKDF-SHA256** avec une clé privée SSH comme second facteur. + +``` +sshHash = SHA256(ssh_private_key_file_bytes) +ikm = HMAC-SHA256(key=sshHash, message=passphrase) +aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +``` + +### Chiffrement + +``` +AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key) +``` + +### Format de transmission + +``` +enc:// +``` + +| Champ | Taille | Description | +|-------|--------|-------------| +| `salt` | 16 octets | Aléatoire par chiffrement ; fourni à HKDF | +| `nonce` | 12 octets | Aléatoire par chiffrement ; IV AES-GCM | +| `ciphertext` | variable | Texte chiffré AES-256-GCM + tag d'authentification de 16 octets | + +Le tag d'authentification GCM est automatiquement ajouté au texte chiffré. Toute altération provoque l'échec du déchiffrement avec une erreur plutôt que de retourner un texte clair corrompu. + +### Performance + +| Opération | Durée (ARM Cortex-A) | +|-----------|----------------------| +| Dérivation de clé (HKDF) | < 1 ms | +| Déchiffrement AES-256-GCM | < 1 ms | +| **Surcoût total au démarrage** | **< 2 ms par clé** | + +--- + +## Sécurité à deux facteurs avec clé SSH + +Lorsqu'une clé privée SSH est fournie, casser le chiffrement nécessite **les deux** : + +1. La **phrase secrète** (`PICOCLAW_KEY_PASSPHRASE`) +2. Le **fichier de clé privée SSH** + +Cela signifie qu'un fichier de configuration divulgué seul ne suffit pas pour récupérer la clé API, même si la phrase secrète est faible. La clé SSH apporte 256 bits d'entropie (Ed25519) indépendamment de la force de la phrase secrète. + +### Modèle de menace + +| Ce que l'attaquant possède | Peut-il déchiffrer ? | +|---------------------------|---------------------| +| Fichier de configuration uniquement | Non — nécessite la phrase secrète + la clé SSH | +| Clé SSH uniquement | Non — nécessite la phrase secrète | +| Phrase secrète uniquement | Non — nécessite la clé SSH | +| Fichier de configuration + clé SSH + phrase secrète | Oui — compromission totale | + +--- + +## Variables d'environnement + +| Variable | Requis | Description | +|----------|--------|-------------| +| `PICOCLAW_KEY_PASSPHRASE` | Oui (pour `enc://`) | Phrase secrète utilisée pour la dérivation de clé | +| `PICOCLAW_SSH_KEY_PATH` | Non | Chemin vers la clé privée SSH. Si non défini, détection automatique depuis `~/.ssh/picoclaw_ed25519.key` | + +### Détection automatique de la clé SSH + +Si `PICOCLAW_SSH_KEY_PATH` n'est pas défini, PicoClaw recherche la clé dédiée : + +``` +~/.ssh/picoclaw_ed25519.key +``` + +Ce fichier dédié évite les conflits avec les clés SSH existantes de l'utilisateur. +Exécutez `picoclaw onboard` pour le générer automatiquement. + +`os.UserHomeDir()` est utilisé pour la résolution multiplateforme du répertoire personnel (lit `USERPROFILE` sous Windows, `HOME` sous Unix/macOS). + +> **Remarque :** Un fichier de clé SSH est requis pour le chiffrement des identifiants. Si aucune clé n'est trouvée et que `PICOCLAW_SSH_KEY_PATH` n'est pas défini, le chiffrement/déchiffrement échouera. Exécutez `picoclaw onboard` pour générer la clé automatiquement. + +--- + +## Migration + +Étant donné que les seuls éléments secrets sont `PICOCLAW_KEY_PASSPHRASE` et le fichier de clé privée SSH, la migration est simple : + +1. Copiez le fichier de configuration sur la nouvelle machine. +2. Définissez `PICOCLAW_KEY_PASSPHRASE` avec la même valeur. +3. Copiez le fichier de clé privée SSH au même chemin (ou définissez `PICOCLAW_SSH_KEY_PATH` vers son nouvel emplacement). + +Aucun re-chiffrement n'est nécessaire. + +--- + +## Considérations de sécurité + +- **La phrase secrète et la clé SSH sont toutes deux requises.** La clé SSH agit comme un second facteur — sans elle, le chiffrement/déchiffrement échouera. Exécutez `picoclaw onboard` pour générer la clé si elle n'existe pas. +- **La clé SSH est en lecture seule à l'exécution.** PicoClaw n'écrit ni ne modifie jamais le fichier de clé SSH. +- **Les clés en texte clair restent prises en charge.** Les configurations existantes sans `enc://` ne sont pas affectées. +- **Le format `enc://` est versionné** via le champ `info` de HKDF (`picoclaw-credential-v1`), permettant de futures mises à niveau d'algorithme sans casser les valeurs chiffrées existantes. diff --git a/docs/fr/debug.md b/docs/fr/debug.md new file mode 100644 index 000000000..5753ccf8c --- /dev/null +++ b/docs/fr/debug.md @@ -0,0 +1,36 @@ +# Débogage de PicoClaw + +> Retour au [README](../../README.fr.md) + +PicoClaw effectue de multiples interactions complexes en arrière-plan pour chaque requête qu'il reçoit — du routage des messages et de l'évaluation de la complexité, à l'exécution des outils et à l'adaptation aux défaillances de modèle. Pouvoir voir exactement ce qui se passe est crucial, non seulement pour résoudre les problèmes potentiels, mais aussi pour véritablement comprendre le fonctionnement de l'agent. + +## Démarrer PicoClaw en mode débogage + +Pour obtenir des informations détaillées sur ce que fait l'agent (requêtes LLM, appels d'outils, routage des messages), vous pouvez démarrer la passerelle PicoClaw avec le drapeau de débogage : + +```bash +picoclaw gateway --debug +# or +picoclaw gateway -d +``` + +Dans ce mode, le système formate les logs de manière détaillée et affiche des aperçus des prompts système et des résultats d'exécution des outils. + +## Désactiver la troncature des logs (logs complets) + +Par défaut, PicoClaw tronque les chaînes très longues (comme le *Prompt Système* ou les résultats JSON volumineux) dans les logs de débogage afin de garder la console lisible. + +Si vous avez besoin d'inspecter la sortie complète d'une commande ou le payload exact envoyé au modèle LLM, vous pouvez utiliser le drapeau `--no-truncate`. + +**Remarque :** Ce drapeau fonctionne *uniquement* en combinaison avec le mode `--debug`. + +```bash +picoclaw gateway --debug --no-truncate + +``` + +Lorsque ce drapeau est actif, la fonction de troncature globale est désactivée. Cela est extrêmement utile pour : + +* Vérifier la syntaxe exacte des messages envoyés au fournisseur. +* Lire la sortie complète d'outils comme `exec`, `web_fetch` ou `read_file`. +* Déboguer l'historique de session sauvegardé en mémoire. diff --git a/docs/fr/docker.md b/docs/fr/docker.md index f17ec355d..432edb1b2 100644 --- a/docs/fr/docker.md +++ b/docs/fr/docker.md @@ -12,6 +12,7 @@ git clone https://github.com/sipeed/picoclaw.git cd picoclaw # 2. Premier lancement — génère automatiquement docker/data/config.json puis s'arrête +# (se déclenche uniquement quand config.json et workspace/ sont tous deux absents) docker compose -f docker/docker-compose.yml --profile gateway up # Le conteneur affiche "First-run setup complete." et s'arrête. diff --git a/docs/fr/hardware-compatibility.md b/docs/fr/hardware-compatibility.md new file mode 100644 index 000000000..c1f397e80 --- /dev/null +++ b/docs/fr/hardware-compatibility.md @@ -0,0 +1,152 @@ +> Retour au [README](../../README.fr.md) + +# 🖥️ PicoClaw Liste de compatibilité matérielle + +PicoClaw fonctionne sur pratiquement n'importe quel appareil Linux. Cette page répertorie les puces, produits et cartes de développement vérifiés. + +**Votre matériel n'est pas listé ?** Soumettez une PR pour l'ajouter ! Les fabricants de matériel sont invités à contribuer et à co-promouvoir. + +--- + +## 1. Support de puces vérifié + +### x86 + +| Fabricant | Puce | Notes | +|-----------|------|-------| +| Intel | Any x86 CPU (i386+) | Tous les processeurs de bureau/serveur/portable | +| AMD | Any x86 CPU | Tous les processeurs de bureau/serveur/portable | + +### ARM + +| Sous-arch | Puces typiques | Notes | +|-----------|----------------|-------| +| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | Monocœur ARM1176JZF-S | +| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | Monocœur Cortex-A7, utilisé dans LicheePi Zero | +| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | Quadricœur Cortex-A53, utilisé dans Orange Pi Zero 3 | +| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | Quadricœur Cortex-A72 | +| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | Quadricœur Cortex-A76 | +| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | Bicœur Cortex-A53 + NPU, utilisé dans NanoKVM-Pro / MaixCAM2 | + +### RISC-V (riscv64) + +| Fabricant | Puce | Cœur | Notes | +|-----------|------|------|-------| +| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 intégré, utilisé dans LicheeRV-Nano / NanoKVM / MaixCAM | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L intégré, 1 TOPS NPU, caméra AI 4K SiP | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | Série de caméras AI RISC-V | +| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | Utilisé dans HaaS506-LD1 RTU industriel | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | Utilisé dans Milk-V Jupiter, BananaPi BPI-F3 | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | Conforme RVA23, RVV 1024 bits, inférence AI FP8 | +| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8 cœurs, 16MB cache L3, classe bureau | +| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU, utilisé dans CanMV-K230 | + +### MIPS + +| Fabricant | Puce | Notes | +|-----------|------|-------| +| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz, utilisé dans de nombreux routeurs OpenWrt (ex. Xiaomi Router 3G) | + +### LoongArch (loong64) + +| Fabricant | Puce | Notes | +|-----------|------|-------| +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | Quadricœur LA464 @ 2.5GHz, bureau/station de travail | +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | Quadricœur 4C/8T @ 2.5GHz, IPC comparable à Intel 10e génération | +| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | Bicœur @ 1GHz, applications industrielles/IoT | + +--- + +## 2. Produits vérifiés (par date de sortie) + +Produits grand public, routeurs et appareils industriels testés avec PicoClaw. + +| Année | Produit | Arch | SoC | RAM | Catégorie | +|-------|---------|------|-----|-----|-----------| +| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | Smartphone | +| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | Tablette | +| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | Routeur (OpenWrt) | +| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | Boîtier TV / Serveur domestique | +| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | Enceinte connectée | +| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM | +| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | RTU industriel | +| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | IP-KVM Pro | +| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | Caméra AI 4K | + +--- + +## 3. Cartes de développement vérifiées (par date de sortie) + +| Année | Carte | Arch | SoC | RAM | Lien d'achat | +|-------|-------|------|-----|-----|--------------| +| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — | +| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — | +| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — | +| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — | +| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) | +| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) | +| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) | +| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) | +| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) | +| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) | +| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) | + +--- + +## 4. Fonctionne également sur + +### Téléphones Android (via Termux) + +Tout téléphone Android ARM64 (2015+) avec 1 Go+ de RAM. Installez [Termux](https://github.com/termux/termux-app), utilisez `proot` pour exécuter PicoClaw. + +> Voir [README : Exécuter sur d'anciens téléphones Android](../../README.fr.md#-run-on-old-android-phones) pour les instructions de configuration. + +### Bureau / Serveur / Cloud + +| Plateforme | Notes | +|------------|-------| +| x86_64 Linux | Binaire natif, aucune dépendance | +| x86_64 Windows | Binaire natif | +| macOS (Intel / Apple Silicon) | Binaire natif | +| Docker (any platform) | `docker compose` en une ligne, voir [Guide Docker](docker.md) | +| OpenWrt routers | Builds MIPS/ARM, nécessite >32 Mo de RAM libre | +| FreeBSD / NetBSD | Builds x86_64 et arm64 disponibles | + +--- + +## 5. Configuration minimale requise + +| Ressource | Minimum | Recommandé | +|-----------|---------|------------| +| RAM | 10 Mo libres | 32 Mo+ libres | +| Stockage | 20 Mo (binaire) | 50 Mo+ (avec espace de travail) | +| CPU | N'importe lequel (monocœur 0,6 GHz+) | — | +| OS | Linux (kernel 3.x+) | Linux 5.x+ | +| Réseau | Requis (pour les appels API LLM) | Ethernet ou WiFi | + +--- + +## 6. Comment tester et contribuer + +```bash +# 1. Télécharger pour votre architecture +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz + +# 2. Initialiser +./picoclaw onboard + +# 3. Tester +./picoclaw agent -m "Hello, what board am I running on?" +``` + +Builds disponibles : `linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle` + +### Ajouter votre matériel + +1. Forkez ce dépôt +2. Ajoutez votre puce / produit / carte dans le tableau approprié +3. Incluez : nom, architecture, SoC, RAM, année et un lien si disponible +4. Soumettez une PR + +Fabricants de matériel : vous souhaitez ajouter un support officiel ou co-promouvoir ? Ouvrez une issue ou contactez-nous via [Discord](https://discord.gg/V4sAZ9XWpN). diff --git a/docs/fr/providers.md b/docs/fr/providers.md index b0b950a44..39f5cf36a 100644 --- a/docs/fr/providers.md +++ b/docs/fr/providers.md @@ -93,7 +93,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti ], "agents": { "defaults": { - "model": "gpt-5.4" + "model_name": "gpt-5.4" } } } @@ -266,13 +266,13 @@ L'ancienne configuration `providers` est **dépréciée** mais toujours prise en ], "agents": { "defaults": { - "model": "glm-4.7" + "model_name": "glm-4.7" } } } ``` -Pour un guide de migration détaillé, voir [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). +Pour un guide de migration détaillé, voir [migration/model-list-migration.md](../migration/model-list-migration.md). ### Architecture des Fournisseurs @@ -298,7 +298,7 @@ Cela maintient le runtime léger tout en faisant des nouveaux backends compatibl "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", + "model_name": "glm-4.7", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 @@ -328,12 +328,11 @@ picoclaw agent -m "Hello" { "agents": { "defaults": { - "model": "anthropic/claude-opus-4-5" + "model_name": "anthropic/claude-opus-4-5" } }, "session": { - "dm_scope": "per-channel-peer", - "backlog_limit": 20 + "dm_scope": "per-channel-peer" }, "providers": { "openrouter": { diff --git a/docs/fr/tools_configuration.md b/docs/fr/tools_configuration.md index 15573fc30..f6e1c0374 100644 --- a/docs/fr/tools_configuration.md +++ b/docs/fr/tools_configuration.md @@ -70,9 +70,32 @@ L'outil exec est utilisé pour exécuter des commandes shell. | Config | Type | Par défaut | Description | |------------------------|-------|------------|------------------------------------------------| +| `enabled` | bool | true | Activer l'outil exec | | `enable_deny_patterns` | bool | true | Activer le blocage par défaut des commandes dangereuses | | `custom_deny_patterns` | array | [] | Modèles de refus personnalisés (expressions régulières) | +### Désactivation de l'Outil Exec + +Pour désactiver complètement l'outil `exec`, définissez `enabled` à `false` : + +**Via le fichier de configuration :** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**Via la variable d'environnement :** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **Note :** Lorsqu'il est désactivé, l'agent ne pourra pas exécuter de commandes shell. Cela affecte également la capacité de l'outil Cron à exécuter des commandes shell planifiées. + ### Fonctionnalité - **`enable_deny_patterns`** : Définir à `false` pour désactiver complètement les modèles de blocage par défaut des commandes dangereuses @@ -329,6 +352,7 @@ Toutes les options de configuration peuvent être remplacées via des variables Par exemple : - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` diff --git a/docs/fr/troubleshooting.md b/docs/fr/troubleshooting.md index bfe8901ef..d2d099ad3 100644 --- a/docs/fr/troubleshooting.md +++ b/docs/fr/troubleshooting.md @@ -16,7 +16,7 @@ **Correction :** Dans `~/.picoclaw/config.json` (ou votre chemin de configuration) : -1. **agents.defaults.model** doit correspondre à un `model_name` dans `model_list` (par ex. `"openrouter-free"`). +1. **agents.defaults.model_name** doit correspondre à un `model_name` dans `model_list` (par ex. `"openrouter-free"`). 2. Le **model** de cette entrée doit être un identifiant de modèle OpenRouter valide, par exemple : - `"openrouter/free"` – niveau gratuit automatique - `"google/gemini-2.0-flash-exp:free"` @@ -28,7 +28,7 @@ Exemple : { "agents": { "defaults": { - "model": "openrouter-free" + "model_name": "openrouter-free" } }, "model_list": [ diff --git a/docs/hardware-compatibility.md b/docs/hardware-compatibility.md new file mode 100644 index 000000000..c11849822 --- /dev/null +++ b/docs/hardware-compatibility.md @@ -0,0 +1,150 @@ +# 🖥️ PicoClaw Hardware Compatibility List + +PicoClaw runs on virtually any Linux device. This page tracks verified chips, products, and development boards. + +**Your hardware not listed?** Submit a PR to add it! Hardware vendors are welcome to contribute and co-promote. + +--- + +## 1. Verified Chip Support + +### x86 + +| Vendor | Chip | Notes | +|--------|------|-------| +| Intel | Any x86 CPU (i386+) | All desktop/server/laptop processors | +| AMD | Any x86 CPU | All desktop/server/laptop processors | + +### ARM + +| Sub-arch | Typical Chips | Notes | +|----------|--------------|-------| +| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | Single-core ARM1176JZF-S | +| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | Single-core Cortex-A7, used in LicheePi Zero | +| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | Quad-core Cortex-A53, used in Orange Pi Zero 3 | +| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | Quad-core Cortex-A72 | +| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | Quad-core Cortex-A76 | +| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | Dual-core Cortex-A53 + NPU, used in NanoKVM-Pro / MaixCAM2 | + +### RISC-V (riscv64) + +| Vendor | Chip | Core | Notes | +|--------|------|------|-------| +| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 on-chip, used in LicheeRV-Nano / NanoKVM / MaixCAM | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L on-chip, 1 TOPS NPU, 4K AI camera SiP | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | RISC-V AI camera series | +| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | Used in HaaS506-LD1 industrial RTU | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | Used in Milk-V Jupiter, BananaPi BPI-F3 | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | RVA23 compliant, 1024-bit RVV, FP8 AI inference | +| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8-core, 16MB L3 cache, desktop-class | +| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU, used in CanMV-K230 | + +### MIPS + +| Vendor | Chip | Notes | +|--------|------|-------| +| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz, used in many OpenWrt routers (e.g. Xiaomi Router 3G) | + +### LoongArch (loong64) + +| Vendor | Chip | Notes | +|--------|------|-------| +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | Quad-core LA464 @ 2.5GHz, desktop/workstation | +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | Quad-core 4C/8T @ 2.5GHz, IPC comparable to Intel 10th gen | +| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | Dual-core @ 1GHz, industrial/IoT applications | + +--- + +## 2. Verified Products (by release date) + +Consumer products, routers, and industrial devices that have been tested with PicoClaw. + +| Year | Product | Arch | SoC | RAM | Category | +|------|---------|------|-----|-----|----------| +| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | Smartphone | +| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | Tablet | +| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | Router (OpenWrt) | +| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | TV Box / Home Server | +| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | Smart Speaker | +| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM | +| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | Industrial RTU | +| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | Pro IP-KVM | +| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | 4K AI Camera | + +--- + +## 3. Verified Development Boards (by release date) + +| Year | Board | Arch | SoC | RAM | Buy Link | +|------|-------|------|-----|-----|----------| +| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — | +| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — | +| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — | +| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — | +| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) | +| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) | +| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) | +| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) | +| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) | +| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) | +| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) | + +--- + +## 4. Also Works On + +### Android Phones (via Termux) + +Any ARM64 Android phone (2015+) with 1GB+ RAM. Install [Termux](https://github.com/termux/termux-app), use `proot` to run PicoClaw. + +> See [README: Run on old Android Phones](../README.md#-run-on-old-android-phones) for setup instructions. + +### Desktop / Server / Cloud + +| Platform | Notes | +|----------|-------| +| x86_64 Linux | Native binary, no dependencies | +| x86_64 Windows | Native binary | +| macOS (Intel / Apple Silicon) | Native binary | +| Docker (any platform) | `docker compose` one-liner, see [Docker Guide](docker.md) | +| OpenWrt routers | MIPS/ARM builds, requires >32MB free RAM | +| FreeBSD / NetBSD | x86_64 and arm64 builds available | + +--- + +## 5. Minimum Requirements + +| Resource | Minimum | Recommended | +|----------|---------|-------------| +| RAM | 10MB free | 32MB+ free | +| Storage | 20MB (binary) | 50MB+ (with workspace) | +| CPU | Any (single core 0.6GHz+) | — | +| OS | Linux (kernel 3.x+) | Linux 5.x+ | +| Network | Required (for LLM API calls) | Ethernet or WiFi | + +--- + +## 6. How to Test & Contribute + +```bash +# 1. Download for your architecture +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz + +# 2. Initialize +./picoclaw onboard + +# 3. Test +./picoclaw agent -m "Hello, what board am I running on?" +``` + +Available builds: `linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle` + +### Add Your Hardware + +1. Fork this repo +2. Add your chip / product / board to the appropriate table +3. Include: name, arch, SoC, RAM, year, and a link if available +4. Submit a PR + +Hardware vendors: want to add official support or co-promote? Open an issue or reach out via [Discord](https://discord.gg/V4sAZ9XWpN). diff --git a/docs/ja/ANTIGRAVITY_AUTH.md b/docs/ja/ANTIGRAVITY_AUTH.md new file mode 100644 index 000000000..b55e4ab1b --- /dev/null +++ b/docs/ja/ANTIGRAVITY_AUTH.md @@ -0,0 +1,809 @@ +> [README](../../README.ja.md) に戻る + +# Antigravity 認証・統合ガイド + +## 概要 + +**Antigravity**(Google Cloud Code Assist)は、Google が提供する AI モデルプロバイダーで、Google のクラウドインフラストラクチャを通じて Claude Opus 4.6 や Gemini などのモデルへのアクセスを提供します。本ドキュメントでは、認証の仕組み、モデルの取得方法、PicoClaw での新しいプロバイダーの実装方法について完全なガイドを提供します。 + +--- + +## 目次 + +1. [認証フロー](#認証フロー) +2. [OAuth 実装の詳細](#oauth-実装の詳細) +3. [トークン管理](#トークン管理) +4. [モデルリストの取得](#モデルリストの取得) +5. [使用量トラッキング](#使用量トラッキング) +6. [プロバイダープラグイン構造](#プロバイダープラグイン構造) +7. [統合要件](#統合要件) +8. [API エンドポイント](#api-エンドポイント) +9. [設定](#設定) +10. [PicoClaw での新しいプロバイダーの作成](#picoclaw-での新しいプロバイダーの作成) + +--- + +## 認証フロー + +### 1. PKCE 付き OAuth 2.0 + +Antigravity はセキュアな認証のために **OAuth 2.0 with PKCE(Proof Key for Code Exchange)** を使用します: + +``` +┌─────────────┐ ┌─────────────────┐ +│ Client │ ───(1) Generate PKCE Pair────────> │ │ +│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │ +│ │ │ Server │ +│ │ <──(3) Redirect with Code───────── │ │ +│ │ └─────────────────┘ +│ │ ───(4) Exchange Code for Tokens──> │ Token URL │ +│ │ │ │ +│ │ <──(5) Access + Refresh Tokens──── │ │ +└─────────────┘ └─────────────────┘ +``` + +### 2. 詳細手順 + +#### ステップ 1:PKCE パラメータの生成 +```typescript +function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} +``` + +#### ステップ 2:認可 URL の構築 +```typescript +const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const REDIRECT_URI = "http://localhost:51121/oauth-callback"; + +function buildAuthUrl(params: { challenge: string; state: string }): string { + const url = new URL(AUTH_URL); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", SCOPES.join(" ")); + url.searchParams.set("code_challenge", params.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", params.state); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + return url.toString(); +} +``` + +**必要なスコープ:** +```typescript +const SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", +]; +``` + +#### ステップ 3:OAuth コールバックの処理 + +**自動モード(ローカル開発):** +- ポート 51121 でローカル HTTP サーバーを起動 +- Google からのリダイレクトを待機 +- クエリパラメータから認可コードを抽出 + +**手動モード(リモート/ヘッドレス):** +- ユーザーに認可 URL を表示 +- ユーザーがブラウザで認証を完了 +- ユーザーが完全なリダイレクト URL をターミナルに貼り付け +- 貼り付けられた URL からコードを解析 + +#### ステップ 4:コードをトークンに交換 +```typescript +const TOKEN_URL = "https://oauth2.googleapis.com/token"; + +async function exchangeCode(params: { + code: string; + verifier: string; +}): Promise<{ access: string; refresh: string; expires: number }> { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: params.code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + code_verifier: params.verifier, + }), + }); + + const data = await response.json(); + + return { + access: data.access_token, + refresh: data.refresh_token, + expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer + }; +} +``` + +#### ステップ 5:追加のユーザーデータの取得 + +**ユーザーメール:** +```typescript +async function fetchUserEmail(accessToken: string): Promise { + const response = await fetch( + "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + const data = await response.json(); + return data.email; +} +``` + +**プロジェクト ID(API 呼び出しに必須):** +```typescript +async function fetchProjectId(accessToken: string): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": JSON.stringify({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }), + }; + + const response = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers, + body: JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + const data = await response.json(); + return data.cloudaicompanionProject || "rising-fact-p41fc"; // デフォルトのフォールバック +} +``` + +--- + +## OAuth 実装の詳細 + +### クライアント認証情報 + +**重要:** これらは pi-ai との同期のためにソースコード内で base64 エンコードされています: + +```typescript +const decode = (s: string) => Buffer.from(s, "base64").toString(); + +const CLIENT_ID = decode( + "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==" +); +const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); +``` + +### OAuth フローモード + +1. **自動フロー**(ブラウザのあるローカルマシン): + - ブラウザを自動的に開く + - ローカルコールバックサーバーがリダイレクトをキャプチャ + - 初回認証後はユーザー操作不要 + +2. **手動フロー**(リモート/ヘッドレス/WSL2): + - 手動コピー&ペースト用の URL を表示 + - ユーザーが外部ブラウザで認証を完了 + - ユーザーが完全なリダイレクト URL を貼り付け + +```typescript +function shouldUseManualOAuthFlow(isRemote: boolean): boolean { + return isRemote || isWSL2Sync(); +} +``` + +--- + +## トークン管理 + +### 認証プロファイル構造 + +```typescript +type OAuthCredential = { + type: "oauth"; + provider: "google-antigravity"; + access: string; // アクセストークン + refresh: string; // リフレッシュトークン + expires: number; // 有効期限タイムスタンプ(エポックからのミリ秒) + email?: string; // ユーザーメール + projectId?: string; // Google Cloud プロジェクト ID +}; +``` + +### トークンの更新 + +認証情報にはリフレッシュトークンが含まれており、現在のアクセストークンが期限切れになった際に新しいアクセストークンを取得するために使用できます。有効期限は競合状態を防ぐために 5 分のバッファを設けています。 + +--- + +## モデルリストの取得 + +### 利用可能なモデルの取得 + +```typescript +const BASE_URL = "https://cloudcode-pa.googleapis.com"; + +async function fetchAvailableModels( + accessToken: string, + projectId: string +): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + }; + + const response = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers, + body: JSON.stringify({ project: projectId }), + } + ); + + const data = await response.json(); + + // クォータ情報付きのモデルを返す + return Object.entries(data.models).map(([modelId, modelInfo]) => ({ + id: modelId, + displayName: modelInfo.displayName, + quotaInfo: { + remainingFraction: modelInfo.quotaInfo?.remainingFraction, + resetTime: modelInfo.quotaInfo?.resetTime, + isExhausted: modelInfo.quotaInfo?.isExhausted, + }, + })); +} +``` + +### レスポンス形式 + +```typescript +type FetchAvailableModelsResponse = { + models?: Record; +}; +``` + +--- + +## 使用量トラッキング + +### 使用量データの取得 + +```typescript +export async function fetchAntigravityUsage( + token: string, + timeoutMs: number +): Promise { + // 1. クレジットとプラン情報を取得 + const loadCodeAssistRes = await fetch( + `${BASE_URL}/v1internal:loadCodeAssist`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: { + ideType: "ANTIGRAVITY", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + // クレジット情報を抽出 + const { availablePromptCredits, planInfo, currentTier } = data; + + // 2. モデルクォータを取得 + const modelsRes = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ project: projectId }), + } + ); + + // 使用量ウィンドウを構築 + return { + provider: "google-antigravity", + displayName: "Google Antigravity", + windows: [ + { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) }, + // 個別モデルクォータ... + ], + plan: currentTier?.name || planType, + }; +} +``` + +### 使用量レスポンス構造 + +```typescript +type ProviderUsageSnapshot = { + provider: "google-antigravity"; + displayName: string; + windows: UsageWindow[]; + plan?: string; + error?: string; +}; + +type UsageWindow = { + label: string; // "Credits" またはモデル ID + usedPercent: number; // 0-100 + resetAt?: number; // クォータがリセットされるタイムスタンプ +}; +``` + +--- + +## プロバイダープラグイン構造 + +### プラグイン定義 + +```typescript +const antigravityPlugin = { + id: "google-antigravity-auth", + name: "Google Antigravity Auth", + description: "OAuth flow for Google Antigravity (Cloud Code Assist)", + configSchema: emptyPluginConfigSchema(), + + register(api: PicoClawPluginApi) { + api.registerProvider({ + id: "google-antigravity", + label: "Google Antigravity", + docsPath: "/providers/models", + aliases: ["antigravity"], + + auth: [ + { + id: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + kind: "oauth", + run: async (ctx: ProviderAuthContext) => { + // OAuth 実装はここに記述 + }, + }, + ], + }); + }, +}; +``` + +### ProviderAuthContext + +```typescript +type ProviderAuthContext = { + config: PicoClawConfig; + agentDir?: string; + workspaceDir?: string; + prompter: WizardPrompter; // UI プロンプト/通知 + runtime: RuntimeEnv; // ログなど + isRemote: boolean; // リモート実行かどうか + openUrl: (url: string) => Promise; // ブラウザオープナー + oauth: { + createVpsAwareHandlers: Function; + }; +}; +``` + +### ProviderAuthResult + +```typescript +type ProviderAuthResult = { + profiles: Array<{ + profileId: string; + credential: AuthProfileCredential; + }>; + configPatch?: Partial; + defaultModel?: string; + notes?: string[]; +}; +``` + +--- + +## 統合要件 + +### 1. 必要な環境/依存関係 + +- Go ≥ 1.25 +- PicoClaw コードベース(`pkg/providers/` および `pkg/auth/`) +- `crypto` および `net/http` 標準ライブラリパッケージ + +### 2. API 呼び出しに必要なヘッダー + +```typescript +const REQUIRED_HEADERS = { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", // または "google-api-nodejs-client/9.15.1" + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", +}; + +// loadCodeAssist 呼び出しには以下も含める: +const CLIENT_METADATA = { + ideType: "ANTIGRAVITY", // または "IDE_UNSPECIFIED" + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", +}; +``` + +### 3. モデルスキーマのサニタイズ + +Antigravity は Gemini 互換モデルを使用するため、ツールスキーマのサニタイズが必要です: + +```typescript +const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([ + "patternProperties", + "additionalProperties", + "$schema", + "$id", + "$ref", + "$defs", + "definitions", + "examples", + "minLength", + "maxLength", + "minimum", + "maximum", + "multipleOf", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems", + "minProperties", + "maxProperties", +]); + +// 送信前にスキーマをクリーンアップ +function cleanToolSchemaForGemini(schema: Record): unknown { + // サポートされていないキーワードを削除 + // トップレベルに type: "object" があることを確認 + // anyOf/oneOf ユニオンをフラット化 +} +``` + +### 4. 思考ブロックの処理(Claude モデル) + +Antigravity の Claude モデルでは、思考ブロックに特別な処理が必要です: + +```typescript +const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +export function sanitizeAntigravityThinkingBlocks( + messages: AgentMessage[] +): AgentMessage[] { + // 思考シグネチャを検証 + // シグネチャフィールドを正規化 + // 署名されていない思考ブロックを破棄 +} +``` + +--- + +## API エンドポイント + +### 認証エンドポイント + +| エンドポイント | メソッド | 用途 | +|---------------|---------|------| +| `https://accounts.google.com/o/oauth2/v2/auth` | GET | OAuth 認可 | +| `https://oauth2.googleapis.com/token` | POST | トークン交換 | +| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | ユーザー情報(メール) | + +### Cloud Code Assist エンドポイント + +| エンドポイント | メソッド | 用途 | +|---------------|---------|------| +| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | プロジェクト情報、クレジット、プランの読み込み | +| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | クォータ付き利用可能モデルの一覧 | +| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | チャットストリーミングエンドポイント | + +**API リクエスト形式(チャット):** +`v1internal:streamGenerateContent` エンドポイントは、標準の Gemini リクエストをラップするエンベロープ形式を期待します: + +```json +{ + "project": "your-project-id", + "model": "model-id", + "request": { + "contents": [...], + "systemInstruction": {...}, + "generationConfig": {...}, + "tools": [...] + }, + "requestType": "agent", + "userAgent": "antigravity", + "requestId": "agent-timestamp-random" +} +``` + +**API レスポンス形式(SSE):** +各 SSE メッセージ(`data: {...}`)は `response` フィールドでラップされます: + +```json +{ + "response": { + "candidates": [...], + "usageMetadata": {...}, + "modelVersion": "...", + "responseId": "..." + }, + "traceId": "...", + "metadata": {} +} +``` + +--- + +## 設定 + +### config.json の設定 + +```json +{ + "model_list": [ + { + "model_name": "gemini-flash", + "model": "antigravity/gemini-3-flash", + "auth_method": "oauth" + } + ], + "agents": { + "defaults": { + "model_name": "gemini-flash" + } + } +} +``` + +### 認証プロファイルの保存 + +認証プロファイルは `~/.picoclaw/auth.json` に保存されます: + +```json +{ + "credentials": { + "google-antigravity": { + "access_token": "ya29...", + "refresh_token": "1//...", + "expires_at": "2026-01-01T00:00:00Z", + "provider": "google-antigravity", + "auth_method": "oauth", + "email": "user@example.com", + "project_id": "my-project-id" + } + } +} +``` + +--- + +## PicoClaw での新しいプロバイダーの作成 + +PicoClaw のプロバイダーは `pkg/providers/` 配下の Go パッケージとして実装されます。新しいプロバイダーを追加するには: + +### ステップバイステップの実装 + +#### 1. プロバイダーファイルの作成 + +`pkg/providers/` に新しい Go ファイルを作成します: + +``` +pkg/providers/ +└── your_provider.go +``` + +#### 2. Provider インターフェースの実装 + +プロバイダーは `pkg/providers/types.go` で定義された `Provider` インターフェースを実装する必要があります: + +```go +package providers + +type YourProvider struct { + apiKey string + apiBase string +} + +func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider { + if apiBase == "" { + apiBase = "https://api.your-provider.com/v1" + } + return &YourProvider{apiKey: apiKey, apiBase: apiBase} +} + +func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error { + // ストリーミング付きチャット補完を実装 +} +``` + +#### 3. ファクトリーへの登録 + +`pkg/providers/factory.go` のプロトコルスイッチにプロバイダーを追加します: + +```go +case "your-provider": + return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil +``` + +#### 4. デフォルト設定の追加(オプション) + +`pkg/config/defaults.go` にデフォルトエントリを追加します: + +```go +{ + ModelName: "your-model", + Model: "your-provider/model-name", + APIKey: "", +}, +``` + +#### 5. 認証サポートの追加(オプション) + +プロバイダーが OAuth や特別な認証を必要とする場合、`cmd/picoclaw/internal/auth/helpers.go` にケースを追加します: + +```go +case "your-provider": + authLoginYourProvider() +``` + +#### 6. `config.json` での設定 + +```json +{ + "model_list": [ + { + "model_name": "your-model", + "model": "your-provider/model-name", + "api_key": "your-api-key", + "api_base": "https://api.your-provider.com/v1" + } + ] +} +``` + +--- + +## 実装のテスト + +### CLI コマンド + +```bash +# プロバイダーで認証 +picoclaw auth login --provider your-provider + +# モデルの一覧表示(Antigravity 用) +picoclaw auth models + +# ゲートウェイの起動 +picoclaw gateway + +# 特定のモデルでエージェントを実行 +picoclaw agent -m "Hello" --model your-model +``` + +### テスト用環境変数 + +```bash +# デフォルトモデルの上書き +export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model + +# プロバイダー設定の上書き +export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]' +``` + +--- + +## 参考資料 + +- **ソースファイル:** + - `pkg/providers/antigravity_provider.go` - Antigravity プロバイダー実装 + - `pkg/auth/oauth.go` - OAuth フロー実装 + - `pkg/auth/store.go` - 認証情報ストレージ(`~/.picoclaw/auth.json`) + - `pkg/providers/factory.go` - プロバイダーファクトリーとプロトコルルーティング + - `pkg/providers/types.go` - プロバイダーインターフェース定義 + - `cmd/picoclaw/internal/auth/helpers.go` - 認証 CLI コマンド + +- **ドキュメント:** + - `docs/ANTIGRAVITY_USAGE.md` - Antigravity 使用ガイド + - `docs/migration/model-list-migration.md` - 移行ガイド + +--- + +## 注意事項 + +1. **Google Cloud プロジェクト:** Antigravity は Google Cloud プロジェクトで Gemini for Google Cloud が有効になっている必要があります +2. **クォータ:** Google Cloud プロジェクトのクォータを使用します(個別の課金ではありません) +3. **モデルアクセス:** 利用可能なモデルは Google Cloud プロジェクトの設定に依存します +4. **思考ブロック:** Antigravity 経由の Claude モデルは、署名付き思考ブロックの特別な処理が必要です +5. **スキーマサニタイズ:** ツールスキーマはサポートされていない JSON Schema キーワードを削除するためにサニタイズが必要です + +--- + +--- + +## 一般的なエラー処理 + +### 1. レート制限(HTTP 429) + +プロジェクト/モデルのクォータが枯渇すると、Antigravity は 429 エラーを返します。エラーレスポンスには通常、`details` フィールドに `quotaResetDelay` が含まれます。 + +**429 エラーの例:** +```json +{ + "error": { + "code": 429, + "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.", + "status": "RESOURCE_EXHAUSTED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "metadata": { + "quotaResetDelay": "4h30m28.060903746s" + } + } + ] + } +} +``` + +### 2. 空のレスポンス(制限付きモデル) + +一部のモデルは利用可能モデルリストに表示されますが、空のレスポンスを返す場合があります(200 OK だが SSE ストリームが空)。これは通常、現在のプロジェクトに使用権限がないプレビュー版または制限付きモデルで発生します。 + +**対処法:** 空のレスポンスをエラーとして扱い、そのモデルがプロジェクトに対して制限されているか無効である可能性があることをユーザーに通知します。 + +--- + +## トラブルシューティング + +### "Token expired"(トークン期限切れ) +- OAuth トークンを更新:`picoclaw auth login --provider antigravity` + +### "Gemini for Google Cloud is not enabled"(Gemini for Google Cloud が有効になっていない) +- Google Cloud Console で API を有効にしてください + +### "Project not found"(プロジェクトが見つからない) +- Google Cloud プロジェクトで必要な API が有効になっていることを確認してください +- 認証中にプロジェクト ID が正しく取得されているか確認してください + +### モデルがリストに表示されない +- OAuth 認証が正常に完了したことを確認してください +- 認証プロファイルストレージを確認:`~/.picoclaw/auth.json` +- `picoclaw auth login --provider antigravity` を再実行してください diff --git a/docs/ja/ANTIGRAVITY_USAGE.md b/docs/ja/ANTIGRAVITY_USAGE.md new file mode 100644 index 000000000..c044c1970 --- /dev/null +++ b/docs/ja/ANTIGRAVITY_USAGE.md @@ -0,0 +1,72 @@ +> [README](../../README.ja.md) に戻る + +# PicoClaw で Antigravity プロバイダーを使用する + +このガイドでは、PicoClaw で **Antigravity**(Google Cloud Code Assist)プロバイダーをセットアップして使用する方法を説明します。 + +## 前提条件 + +1. Google アカウント。 +2. Google Cloud Code Assist が有効であること(通常「Gemini for Google Cloud」のオンボーディングから利用可能)。 + +## 1. 認証 + +Antigravity で認証するには、以下のコマンドを実行します: + +```bash +picoclaw auth login --provider antigravity +``` + +### 手動認証(ヘッドレス/VPS) +サーバー(Coolify/Docker)上で実行しており、`localhost` にアクセスできない場合は、以下の手順に従ってください: +1. 上記のコマンドを実行します。 +2. 表示された URL をコピーし、ローカルブラウザで開きます。 +3. ログインを完了します。 +4. ブラウザが `localhost:51121` URL にリダイレクトされます(ページは読み込めません)。 +5. **ブラウザのアドレスバーからその最終 URL をコピーします**。 +6. **PicoClaw が待機しているターミナルにそれを貼り付けます**。 + +PicoClaw が自動的に認証コードを抽出し、プロセスを完了します。 + +## 2. モデルの管理 + +### 利用可能なモデルの一覧 +プロジェクトがアクセスできるモデルとそのクォータを確認するには: + +```bash +picoclaw auth models +``` + +### モデルの切り替え +`~/.picoclaw/config.json` でデフォルトモデルを変更するか、CLI でオーバーライドできます: + +```bash +# 単一コマンドでオーバーライド +picoclaw agent -m "Hello" --model claude-opus-4-6-thinking +``` + +## 3. 実際の使用方法(Coolify/Docker) + +Coolify または Docker でデプロイしている場合、以下の手順でテストしてください: + +1. **環境変数**: + * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash` +2. **認証の永続化**: + ローカルでログイン済みの場合、認証情報をサーバーにコピーできます: + ```bash + scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/ + ``` + *または*、ターミナルアクセスがある場合、サーバー上で `auth login` コマンドを一度実行してください。 + +## 4. トラブルシューティング + +* **空のレスポンス**:モデルが空の応答を返す場合、プロジェクトで制限されている可能性があります。`gemini-3-flash` または `claude-opus-4-6-thinking` を試してください。 +* **429 レート制限**:Antigravity には厳格なクォータがあります。制限に達した場合、PicoClaw はエラーメッセージに「リセット時間」を表示します。 +* **404 Not Found**:`picoclaw auth models` リストのモデル ID を使用していることを確認してください。フルパスではなく、短い ID(例:`gemini-3-flash`)を使用してください。 + +## 5. 動作確認済みモデルのまとめ + +テストに基づき、以下のモデルが最も信頼性が高いです: +* `gemini-3-flash`(高速、高可用性) +* `gemini-2.5-flash-lite`(軽量) +* `claude-opus-4-6-thinking`(高性能、推論機能を含む) diff --git a/docs/ja/chat-apps.md b/docs/ja/chat-apps.md index 6d01c817b..997a064ff 100644 --- a/docs/ja/chat-apps.md +++ b/docs/ja/chat-apps.md @@ -12,19 +12,19 @@ PicoClaw は複数のチャットプラットフォームをサポートして | チャネル | セットアップ難易度 | 特徴 | ドキュメント | | -------------------- | ------------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| **Telegram** | ⭐ 簡単 | 推奨、音声テキスト変換対応、ロングポーリング(公開 IP 不要) | [ドキュメント](../channels/telegram/README.zh.md) | -| **Discord** | ⭐ 簡単 | Socket Mode、グループ/DM 対応、Bot エコシステム充実 | [ドキュメント](../channels/discord/README.zh.md) | -| **WhatsApp** | ⭐ 簡単 | ネイティブ (QR スキャン) または Bridge URL | [ドキュメント](../channels/whatsapp/README.zh.md) | -| **Slack** | ⭐ 簡単 | **Socket Mode** (公開 IP 不要)、エンタープライズ対応 | [ドキュメント](../channels/slack/README.zh.md) | -| **Matrix** | ⭐⭐ 中程度 | フェデレーションプロトコル、セルフホスト対応 | [ドキュメント](../channels/matrix/README.zh.md) | -| **QQ** | ⭐⭐ 中程度 | 公式ボット API、中国コミュニティ向け | [ドキュメント](../channels/qq/README.zh.md) | -| **DingTalk** | ⭐⭐ 中程度 | Stream モード(公開 IP 不要)、企業向け | [ドキュメント](../channels/dingtalk/README.zh.md) | -| **LINE** | ⭐⭐⭐ やや難 | HTTPS Webhook が必要 | [ドキュメント](../channels/line/README.zh.md) | -| **WeCom (企業微信)** | ⭐⭐⭐ やや難 | グループ Bot (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) | +| **Telegram** | ⭐ 簡単 | 推奨、音声テキスト変換対応、ロングポーリング(公開 IP 不要) | [ドキュメント](../channels/telegram/README.ja.md) | +| **Discord** | ⭐ 簡単 | Socket Mode、グループ/DM 対応、Bot エコシステム充実 | [ドキュメント](../channels/discord/README.ja.md) | +| **WhatsApp** | ⭐ 簡単 | ネイティブ (QR スキャン) または Bridge URL | [ドキュメント](#whatsapp) | +| **Slack** | ⭐ 簡単 | **Socket Mode** (公開 IP 不要)、エンタープライズ対応 | [ドキュメント](../channels/slack/README.ja.md) | +| **Matrix** | ⭐⭐ 中程度 | フェデレーションプロトコル、セルフホスト対応 | [ドキュメント](../channels/matrix/README.ja.md) | +| **QQ** | ⭐⭐ 中程度 | 公式ボット API、中国コミュニティ向け | [ドキュメント](../channels/qq/README.ja.md) | +| **DingTalk** | ⭐⭐ 中程度 | Stream モード(公開 IP 不要)、企業向け | [ドキュメント](../channels/dingtalk/README.ja.md) | +| **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 設定 | - | -| **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.zh.md) | -| **MaixCam** | ⭐ 簡単 | Sipeed AI カメラハードウェア統合チャネル | [ドキュメント](../channels/maixcam/README.zh.md) | +| **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.ja.md) | +| **MaixCam** | ⭐ 簡単 | Sipeed AI カメラハードウェア統合チャネル | [ドキュメント](../channels/maixcam/README.ja.md) | | **Pico** | ⭐ 簡単 | PicoClaw ネイティブプロトコルチャネル | | --- @@ -207,12 +207,13 @@ picoclaw gateway
QQ -**1. Bot を作成** +**クイックセットアップ(推奨)** -- [QQ 開放プラットフォーム](https://q.qq.com/#) にアクセス -- アプリケーションを作成 → **AppID** と **AppSecret** を取得 +QQ 開放プラットフォームでは、OpenClaw 互換ボットのワンクリックセットアップページが提供されています: -**2. 設定** +1. [QQ Bot クイックスタート](https://q.qq.com/qqbot/openclaw/index.html) を開き、QR コードをスキャンしてログイン +2. ボットが自動的に作成されます — **App ID** と **App Secret** をコピー +3. PicoClaw を設定: ```json { @@ -227,13 +228,20 @@ picoclaw gateway } ``` -> `allow_from` を空にするとすべてのユーザーを許可します。QQ 番号を指定してアクセスを制限することもできます。 +4. `picoclaw gateway` を実行し、QQ を開いてボットとチャット -**3. 実行** +> App Secret は一度しか表示されません。すぐに保存してください — 再度表示するとリセットされます。 +> +> クイックセットアップで作成されたボットは、最初は作成者のみが使用でき、グループチャットには対応していません。グループアクセスを有効にするには、[QQ 開放プラットフォーム](https://q.qq.com/) でサンドボックスモードを設定してください。 -```bash -picoclaw gateway -``` +**手動セットアップ** + +ボットを手動で作成する場合: + +* [QQ 開放プラットフォーム](https://q.qq.com/) にログインして開発者登録 +* QQ ボットを作成 — アバターと名前をカスタマイズ +* ボット設定から **App ID** と **App Secret** をコピー +* 上記の設定を行い、`picoclaw gateway` を実行
@@ -242,9 +250,10 @@ picoclaw gateway **1. Slack App を作成** -* [Slack API](https://api.slack.com/apps) でアプリを作成 -* **Socket Mode** を有効化 -* **Bot Token** と **App-Level Token** を取得 +* [Slack API](https://api.slack.com/apps) にアクセスして新しいアプリを作成 +* **OAuth & Permissions** で Bot スコープを追加:`chat:write`、`app_mentions:read`、`im:history`、`im:read`、`im:write` +* アプリをワークスペースにインストール +* **Bot Token**(`xoxb-...`)と **App-Level Token**(`xapp-...`、Socket Mode を有効にして取得)をコピー **2. 設定** @@ -253,8 +262,8 @@ picoclaw gateway "channels": { "slack": { "enabled": true, - "bot_token": "xoxb-YOUR_BOT_TOKEN", - "app_token": "xapp-YOUR_APP_TOKEN", + "bot_token": "xoxb-YOUR-BOT-TOKEN", + "app_token": "xapp-YOUR-APP-TOKEN", "allow_from": [] } } @@ -280,21 +289,26 @@ picoclaw gateway "irc": { "enabled": true, "server": "irc.libera.chat:6697", + "tls": true, "nick": "picoclaw-bot", - "use_tls": true, - "channels_to_join": ["#your-channel"], + "channels": ["#your-channel"], + "password": "", "allow_from": [] } } } ``` +オプション:NickServ 認証用の `nickserv_password`、SASL 認証用の `sasl_user`/`sasl_password`。 + **2. 実行** ```bash picoclaw gateway ``` +ボットは IRC サーバーに接続し、指定されたチャネルに参加します。 +
@@ -382,11 +396,14 @@ picoclaw gateway
Feishu (飛書) +PicoClaw は WebSocket/SDK モードで飛書に接続します — 公開 Webhook URL やコールバックサーバーは不要です。 + **1. アプリを作成** -* [飛書開放プラットフォーム](https://open.feishu.cn/) にアクセス -* 企業カスタムアプリを作成 -* **App ID** と **App Secret** を取得 +* [飛書開放プラットフォーム](https://open.feishu.cn/) にアクセスしてアプリケーションを作成 +* アプリ設定で **ボット** 機能を有効化 +* バージョンを作成してアプリを公開(アプリは公開しないと有効になりません) +* **App ID**(`cli_` で始まる)と **App Secret** をコピー **2. 設定** @@ -396,21 +413,25 @@ picoclaw gateway "feishu": { "enabled": true, "app_id": "cli_xxx", - "app_secret": "xxx", - "encrypt_key": "", - "verification_token": "", + "app_secret": "YOUR_APP_SECRET", "allow_from": [] } } } ``` -**3. 実行** +オプション:`encrypt_key` と `verification_token` でイベント暗号化(本番環境推奨)。 + +**3. 実行してチャット** ```bash picoclaw gateway ``` +飛書を開き、ボット名を検索してチャットを開始できます。ボットをグループに追加することもできます — `group_trigger.mention_only: true` を設定すると @メンション時のみ応答します。 + +詳細なオプションについては [飛書チャネル設定ガイド](../channels/feishu/README.ja.md) を参照してください。 +
@@ -422,7 +443,7 @@ PicoClaw は 3 種類の WeCom 統合をサポートしています: **方式 2: カスタムアプリ (App)** — より多機能、プロアクティブメッセージング、プライベートチャットのみ **方式 3: AI Bot** — 公式 AI Bot、ストリーミング返信、グループ・プライベートチャット対応 -詳細なセットアップ手順は [WeCom AI Bot 設定ガイド](../channels/wecom/wecom_aibot/README.zh.md) を参照してください。 +詳細なセットアップ手順は [WeCom AI Bot 設定ガイド](../channels/wecom/wecom_aibot/README.ja.md) を参照してください。 **クイックセットアップ — グループ Bot:** @@ -496,7 +517,7 @@ picoclaw gateway **1. AI Bot を作成** * WeCom 管理コンソール → アプリ管理 → AI Bot -* AI Bot 設定でコールバック URL を設定:`http://your-server:18791/webhook/wecom-aibot` +* AI Bot 設定でコールバック URL を設定:`http://your-server:18790/webhook/wecom-aibot` * **Token** をコピーし、「ランダム生成」をクリックして **EncodingAESKey** を取得 **2. 設定** @@ -510,7 +531,8 @@ picoclaw gateway "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], - "welcome_message": "こんにちは!何かお手伝いできますか?" + "welcome_message": "こんにちは!何かお手伝いできますか?", + "processing_message": "⏳ Processing, please wait. The results will be sent shortly." } } } @@ -527,24 +549,36 @@ picoclaw gateway
-OneBot +OneBot(OneBot プロトコル経由の QQ) -**1. 設定** +OneBot は QQ ボット向けのオープンプロトコルです。PicoClaw は OneBot v11 互換の実装(例:[Lagrange](https://github.com/LagrangeDev/Lagrange.Core)、[NapCat](https://github.com/NapNeko/NapCatQQ))に WebSocket で接続します。 -NapCat / Go-CQHTTP などの OneBot 実装と互換性があります。 +**1. OneBot 実装をセットアップ** + +OneBot v11 互換の QQ ボットフレームワークをインストールして実行します。WebSocket サーバーを有効にしてください。 + +**2. 設定** ```json { "channels": { "onebot": { "enabled": true, + "ws_url": "ws://127.0.0.1:8080", + "access_token": "", "allow_from": [] } } } ``` -**2. 実行** +| フィールド | 説明 | +|-------|-------------| +| `ws_url` | OneBot 実装の WebSocket URL | +| `access_token` | 認証用アクセストークン(OneBot 側で設定している場合) | +| `reconnect_interval` | 再接続間隔(秒)(デフォルト:5) | + +**3. 実行** ```bash picoclaw gateway diff --git a/docs/ja/configuration.md b/docs/ja/configuration.md index c0f68f85b..215b35d54 100644 --- a/docs/ja/configuration.md +++ b/docs/ja/configuration.md @@ -57,7 +57,7 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw 1. `~/.picoclaw/workspace/skills`(ワークスペース) 2. `~/.picoclaw/skills`(グローバル) -3. `/skills`(ビルトイン) +3. `<ビルド時埋め込みパス>/skills`(ビルトイン) 高度な/テスト用セットアップでは、以下の環境変数でビルトインスキルのルートを上書きできます: diff --git a/docs/ja/credential_encryption.md b/docs/ja/credential_encryption.md new file mode 100644 index 000000000..ea74b65d2 --- /dev/null +++ b/docs/ja/credential_encryption.md @@ -0,0 +1,158 @@ +> [README](../../README.ja.md) に戻る + +# クレデンシャル暗号化 + +PicoClaw は `model_list` 設定エントリの `api_key` 値の暗号化をサポートしています。 +暗号化されたキーは `enc://` 文字列として保存され、起動時に自動的に復号されます。 + +--- + +## クイックスタート + +**1. パスフレーズを設定する** + +```bash +export PICOCLAW_KEY_PASSPHRASE="your-passphrase" +``` + +**2. API キーを暗号化する** + +`picoclaw onboard` を実行します — パスフレーズの入力を求められ、SSH キーが生成されます。 +その後、次の `SaveConfig` 呼び出し時に、設定内のすべての平文 `api_key` エントリが自動的に再暗号化されます。生成される `enc://` 値は以下のようになります: + +``` +enc://AAAA...base64... +``` + +**3. 出力を設定に貼り付ける** + +```json +{ + "model_list": [ + { + "model_name": "gpt-4o", + "model": "openai/gpt-4o", + "api_key": "enc://AAAA...base64...", + "api_base": "https://api.openai.com/v1" + } + ] +} +``` + +--- + +## サポートされる `api_key` 形式 + +| 形式 | 例 | 動作 | +|------|---|------| +| 平文 | `sk-abc123` | そのまま使用 | +| ファイル参照 | `file://openai.key` | 設定ファイルと同じディレクトリから内容を読み取り | +| 暗号化 | `enc://` | 起動時に `PICOCLAW_KEY_PASSPHRASE` を使用して復号 | +| 空 | `""` | そのまま渡される(`auth_method: oauth` で使用) | + +--- + +## 暗号設計 + +### 鍵導出 + +暗号化には **HKDF-SHA256** を使用し、SSH 秘密鍵を第二要素とします。 + +``` +sshHash = SHA256(ssh_private_key_file_bytes) +ikm = HMAC-SHA256(key=sshHash, message=passphrase) +aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +``` + +### 暗号化 + +``` +AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key) +``` + +### ワイヤーフォーマット + +``` +enc:// +``` + +| フィールド | サイズ | 説明 | +|-----------|--------|------| +| `salt` | 16 バイト | 暗号化ごとにランダム生成;HKDF に入力 | +| `nonce` | 12 バイト | 暗号化ごとにランダム生成;AES-GCM IV | +| `ciphertext` | 可変 | AES-256-GCM 暗号文 + 16 バイト認証タグ | + +GCM 認証タグは暗号文に自動的に付加されます。改ざんがあった場合、破損した平文を返すのではなく、エラーで復号が失敗します。 + +### パフォーマンス + +| 操作 | 所要時間 (ARM Cortex-A) | +|------|------------------------| +| 鍵導出 (HKDF) | < 1 ms | +| AES-256-GCM 復号 | < 1 ms | +| **起動時の総オーバーヘッド** | **キーあたり < 2 ms** | + +--- + +## SSH キーによる二要素セキュリティ + +SSH 秘密鍵が提供されている場合、暗号を破るには**両方**が必要です: + +1. **パスフレーズ** (`PICOCLAW_KEY_PASSPHRASE`) +2. **SSH 秘密鍵ファイル** + +これは、設定ファイルが漏洩しただけでは、パスフレーズが弱い場合でも API キーを復元できないことを意味します。SSH キーはパスフレーズの強度に関係なく、256 ビットのエントロピー(Ed25519)を提供します。 + +### 脅威モデル + +| 攻撃者が持っているもの | 復号可能か? | +|----------------------|-------------| +| 設定ファイルのみ | いいえ — パスフレーズ + SSH キーが必要 | +| SSH キーのみ | いいえ — パスフレーズが必要 | +| パスフレーズのみ | いいえ — SSH キーが必要 | +| 設定ファイル + SSH キー + パスフレーズ | はい — 完全な侵害 | + +--- + +## 環境変数 + +| 変数 | 必須 | 説明 | +|------|------|------| +| `PICOCLAW_KEY_PASSPHRASE` | はい(`enc://` 使用時) | 鍵導出に使用するパスフレーズ | +| `PICOCLAW_SSH_KEY_PATH` | いいえ | SSH 秘密鍵のパス。未設定の場合、`~/.ssh/picoclaw_ed25519.key` から自動検出 | + +### SSH キーの自動検出 + +`PICOCLAW_SSH_KEY_PATH` が設定されていない場合、PicoClaw は専用キーを探します: + +``` +~/.ssh/picoclaw_ed25519.key +``` + +この専用ファイルにより、ユーザーの既存の SSH キーとの競合を回避します。 +`picoclaw onboard` を実行すると自動的に生成されます。 + +`os.UserHomeDir()` はクロスプラットフォームのホームディレクトリ解決に使用されます(Windows では `USERPROFILE`、Unix/macOS では `HOME` を読み取ります)。 + +> **注意:** SSH キーファイルはクレデンシャル暗号化に必須です。キーが見つからず `PICOCLAW_SSH_KEY_PATH` も設定されていない場合、暗号化/復号は失敗します。`picoclaw onboard` を実行してキーを自動生成してください。 + +--- + +## 移行 + +唯一の秘密情報は `PICOCLAW_KEY_PASSPHRASE` と SSH 秘密鍵ファイルであるため、移行は簡単です: + +1. 設定ファイルを新しいマシンにコピーします。 +2. `PICOCLAW_KEY_PASSPHRASE` を同じ値に設定します。 +3. SSH 秘密鍵ファイルを同じパスにコピーします(または `PICOCLAW_SSH_KEY_PATH` を新しい場所に設定します)。 + +再暗号化は不要です。 + +--- + +## セキュリティに関する考慮事項 + +- **パスフレーズと SSH キーの両方が必須です。** SSH キーは第二要素として機能します — これがなければ暗号化/復号は失敗します。キーが存在しない場合は `picoclaw onboard` を実行して生成してください。 +- **SSH キーは実行時に読み取り専用です。** PicoClaw は SSH キーファイルへの書き込みや変更を行いません。 +- **平文キーは引き続きサポートされます。** `enc://` を使用しない既存の設定は影響を受けません。 +- **`enc://` 形式はバージョン管理されています。** HKDF `info` フィールド(`picoclaw-credential-v1`)により、既存の暗号化値を壊すことなく将来のアルゴリズムアップグレードが可能です。 diff --git a/docs/ja/debug.md b/docs/ja/debug.md new file mode 100644 index 000000000..ecc52f454 --- /dev/null +++ b/docs/ja/debug.md @@ -0,0 +1,36 @@ +# PicoClaw のデバッグ + +> [README](../../README.ja.md) に戻る + +PicoClaw は、受信するすべてのリクエストに対して、メッセージのルーティングや複雑度の評価、ツールの実行、モデル障害への適応など、多くの複雑な処理をバックグラウンドで実行しています。何が起きているかを正確に把握できることは、潜在的な問題のトラブルシューティングだけでなく、エージェントの動作を真に理解するためにも非常に重要です。 + +## デバッグモードで PicoClaw を起動する + +エージェントの動作に関する詳細情報(LLM リクエスト、ツール呼び出し、メッセージルーティング)を取得するには、デバッグフラグを付けて PicoClaw ゲートウェイを起動します: + +```bash +picoclaw gateway --debug +# or +picoclaw gateway -d +``` + +このモードでは、システムがログを詳細にフォーマットし、システムプロンプトやツール実行結果のプレビューを表示します。 + +## ログの切り詰めを無効にする(完全なログ) + +デフォルトでは、PicoClaw はコンソールの可読性を保つために、デバッグログ内の非常に長い文字列(*システムプロンプト*や大きな JSON 出力結果など)を切り詰めます。 + +コマンドの完全な出力や、LLM モデルに送信された正確なペイロードを確認する必要がある場合は、`--no-truncate` フラグを使用できます。 + +**注意:** このフラグは `--debug` モードと組み合わせた場合に*のみ*機能します。 + +```bash +picoclaw gateway --debug --no-truncate + +``` + +このフラグが有効な場合、グローバルな切り詰め機能が無効になります。これは以下の場合に非常に便利です: + +* プロバイダーに送信されるメッセージの正確な構文を確認する。 +* `exec`、`web_fetch`、`read_file` などのツールの完全な出力を読む。 +* メモリに保存されたセッション履歴をデバッグする。 diff --git a/docs/ja/docker.md b/docs/ja/docker.md index 6ad55d41d..31ed17ec5 100644 --- a/docs/ja/docker.md +++ b/docs/ja/docker.md @@ -12,6 +12,7 @@ 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 gateway up # コンテナが "First-run setup complete." と表示して停止します diff --git a/docs/ja/hardware-compatibility.md b/docs/ja/hardware-compatibility.md new file mode 100644 index 000000000..96ccd1cd1 --- /dev/null +++ b/docs/ja/hardware-compatibility.md @@ -0,0 +1,152 @@ +> [README](../../README.ja.md) に戻る + +# 🖥️ PicoClaw ハードウェア互換性リスト + +PicoClaw はほぼすべての Linux デバイスで動作します。このページでは、検証済みのチップ、製品、開発ボードを記録しています。 + +**お使いのハードウェアがリストにない場合は?** PR を送信して追加してください!ハードウェアベンダーの貢献と共同プロモーションを歓迎します。 + +--- + +## 1. 検証済みチップサポート + +### x86 + +| ベンダー | チップ | 備考 | +|----------|--------|------| +| Intel | Any x86 CPU (i386+) | すべてのデスクトップ/サーバー/ノートPC プロセッサ | +| AMD | Any x86 CPU | すべてのデスクトップ/サーバー/ノートPC プロセッサ | + +### ARM + +| サブアーキテクチャ | 代表的なチップ | 備考 | +|--------------------|----------------|------| +| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | シングルコア ARM1176JZF-S | +| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | シングルコア Cortex-A7、LicheePi Zero で使用 | +| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | クアッドコア Cortex-A53、Orange Pi Zero 3 で使用 | +| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | クアッドコア Cortex-A72 | +| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | クアッドコア Cortex-A76 | +| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | デュアルコア Cortex-A53 + NPU、NanoKVM-Pro / MaixCAM2 で使用 | + +### RISC-V (riscv64) + +| ベンダー | チップ | コア | 備考 | +|----------|--------|------|------| +| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 オンチップ、LicheeRV-Nano / NanoKVM / MaixCAM で使用 | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L オンチップ、1 TOPS NPU、4K AI カメラ SiP | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | RISC-V AI カメラシリーズ | +| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | HaaS506-LD1 産業用 RTU で使用 | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | Milk-V Jupiter, BananaPi BPI-F3 で使用 | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | RVA23 準拠、1024 ビット RVV、FP8 AI 推論 | +| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8 コア、16MB L3 キャッシュ、デスクトップクラス | +| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU、CanMV-K230 で使用 | + +### MIPS + +| ベンダー | チップ | 備考 | +|----------|--------|------| +| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz、多くの OpenWrt ルーターで使用(例:Xiaomi Router 3G) | + +### LoongArch (loong64) + +| ベンダー | チップ | 備考 | +|----------|--------|------| +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | クアッドコア LA464 @ 2.5GHz、デスクトップ/ワークステーション | +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | クアッドコア 4C/8T @ 2.5GHz、IPC は Intel 第10世代に匹敵 | +| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | デュアルコア @ 1GHz、産業/IoT アプリケーション | + +--- + +## 2. 検証済み製品(発売日順) + +PicoClaw でテスト済みのコンシューマー製品、ルーター、産業用デバイス。 + +| 年 | 製品 | アーキテクチャ | SoC | RAM | カテゴリ | +|----|------|----------------|-----|-----|----------| +| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | スマートフォン | +| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | タブレット | +| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | ルーター (OpenWrt) | +| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | TV ボックス / ホームサーバー | +| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | スマートスピーカー | +| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM | +| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | 産業用 RTU | +| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | プロ IP-KVM | +| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | 4K AI カメラ | + +--- + +## 3. 検証済み開発ボード(発売日順) + +| 年 | ボード | アーキテクチャ | SoC | RAM | 購入リンク | +|----|--------|----------------|-----|-----|------------| +| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — | +| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — | +| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — | +| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — | +| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) | +| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) | +| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) | +| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) | +| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) | +| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) | +| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) | + +--- + +## 4. その他の対応環境 + +### Android スマートフォン(Termux 経由) + +1GB 以上の RAM を搭載した ARM64 Android スマートフォン(2015年以降)。[Termux](https://github.com/termux/termux-app) をインストールし、`proot` を使用して PicoClaw を実行します。 + +> セットアップ手順は [README:古い Android スマートフォンで実行](../../README.ja.md#-run-on-old-android-phones) を参照してください。 + +### デスクトップ / サーバー / クラウド + +| プラットフォーム | 備考 | +|------------------|------| +| x86_64 Linux | ネイティブバイナリ、依存関係なし | +| x86_64 Windows | ネイティブバイナリ | +| macOS (Intel / Apple Silicon) | ネイティブバイナリ | +| Docker (any platform) | `docker compose` ワンライナー、[Docker ガイド](docker.md) を参照 | +| OpenWrt routers | MIPS/ARM ビルド、32MB 以上の空きメモリが必要 | +| FreeBSD / NetBSD | x86_64 および arm64 ビルドが利用可能 | + +--- + +## 5. 最小要件 + +| リソース | 最小 | 推奨 | +|----------|------|------| +| RAM | 10MB 空き | 32MB 以上空き | +| ストレージ | 20MB(バイナリ) | 50MB 以上(ワークスペース含む) | +| CPU | 任意(シングルコア 0.6GHz 以上) | — | +| OS | Linux (kernel 3.x+) | Linux 5.x+ | +| ネットワーク | 必須(LLM API 呼び出し用) | イーサネットまたは WiFi | + +--- + +## 6. テストと貢献の方法 + +```bash +# 1. お使いのアーキテクチャ向けをダウンロード +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz + +# 2. 初期化 +./picoclaw onboard + +# 3. テスト +./picoclaw agent -m "Hello, what board am I running on?" +``` + +利用可能なビルド:`linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle` + +### ハードウェアを追加する + +1. このリポジトリをフォーク +2. 該当するテーブルにチップ/製品/ボードを追加 +3. 名前、アーキテクチャ、SoC、RAM、年、リンク(あれば)を含める +4. PR を送信 + +ハードウェアベンダーの方へ:公式サポートの追加や共同プロモーションをご希望ですか?Issue を作成するか、[Discord](https://discord.gg/V4sAZ9XWpN) でお問い合わせください。 diff --git a/docs/ja/providers.md b/docs/ja/providers.md index 2323a27cc..9a53a4b69 100644 --- a/docs/ja/providers.md +++ b/docs/ja/providers.md @@ -93,7 +93,7 @@ ], "agents": { "defaults": { - "model": "gpt-5.4" + "model_name": "gpt-5.4" } } } @@ -266,7 +266,7 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック ], "agents": { "defaults": { - "model": "glm-4.7" + "model_name": "glm-4.7" } } } @@ -298,7 +298,7 @@ PicoClaw はプロトコルファミリーごとに Provider をルーティン "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", + "model_name": "glm-4.7", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 @@ -328,12 +328,11 @@ picoclaw agent -m "こんにちは" { "agents": { "defaults": { - "model": "anthropic/claude-opus-4-5" + "model_name": "anthropic/claude-opus-4-5" } }, "session": { - "dm_scope": "per-channel-peer", - "backlog_limit": 20 + "dm_scope": "per-channel-peer" }, "providers": { "openrouter": { diff --git a/docs/ja/tools_configuration.md b/docs/ja/tools_configuration.md index e4568f6ae..c40e58538 100644 --- a/docs/ja/tools_configuration.md +++ b/docs/ja/tools_configuration.md @@ -70,9 +70,32 @@ Exec ツールはシェルコマンドの実行に使用されます。 | 設定項目 | 型 | デフォルト | 説明 | |------------------------|-------|------------|------------------------------------| +| `enabled` | bool | true | Exec ツールを有効にする | | `enable_deny_patterns` | bool | true | デフォルトの危険コマンドブロックを有効にする | | `custom_deny_patterns` | array | [] | カスタム拒否パターン(正規表現) | +### Exec ツールの無効化 + +`exec` ツールを完全に無効にするには、`enabled` を `false` に設定します: + +**設定ファイル経由:** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**環境変数経由:** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **注意:** 無効にすると、エージェントはシェルコマンドを実行できなくなります。これは Cron ツールがスケジュールされたシェルコマンドを実行する能力にも影響します。 + ### 機能 - **`enable_deny_patterns`**:`false` に設定すると、デフォルトの危険コマンドブロックパターンを完全に無効にします @@ -329,6 +352,7 @@ Skills ツールは ClawHub などのレジストリを通じたスキルの発 例: - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` diff --git a/docs/ja/troubleshooting.md b/docs/ja/troubleshooting.md index 1c98224b9..f18b456db 100644 --- a/docs/ja/troubleshooting.md +++ b/docs/ja/troubleshooting.md @@ -16,7 +16,7 @@ **修正方法:** `~/.picoclaw/config.json`(またはお使いの設定パス)で: -1. **agents.defaults.model** は `model_list` 内の `model_name` と一致する必要があります(例:`"openrouter-free"`)。 +1. **agents.defaults.model_name** は `model_list` 内の `model_name` と一致する必要があります(例:`"openrouter-free"`)。 2. そのエントリの **model** は有効な OpenRouter モデル ID である必要があります。例: - `"openrouter/free"` – 自動無料枠 - `"google/gemini-2.0-flash-exp:free"` @@ -28,7 +28,7 @@ { "agents": { "defaults": { - "model": "openrouter-free" + "model_name": "openrouter-free" } }, "model_list": [ diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md index eed228d4d..9d05ac599 100644 --- a/docs/migration/model-list-migration.md +++ b/docs/migration/model-list-migration.md @@ -70,7 +70,7 @@ The new `model_list` configuration offers several advantages: ], "agents": { "defaults": { - "model": "gpt4" + "model_name": "gpt4" } } } @@ -184,7 +184,7 @@ During the migration period, your existing `providers` configuration will contin - [ ] Identify all providers you're currently using - [ ] Create `model_list` entries for each provider - [ ] Use appropriate protocol prefixes -- [ ] Update `agents.defaults.model` to reference the new `model_name` +- [ ] Update `agents.defaults.model_name` to reference the new `model_name` - [ ] Test that all models work correctly - [ ] Remove or comment out the old `providers` section @@ -196,7 +196,7 @@ During the migration period, your existing `providers` configuration will contin model "xxx" not found in model_list or providers ``` -**Solution**: Ensure the `model_name` in `model_list` matches the value in `agents.defaults.model`. +**Solution**: Ensure the `model_name` in `model_list` matches the value in `agents.defaults.model_name`. ### Unknown protocol error diff --git a/docs/providers.md b/docs/providers.md index e62cbb969..dde1814fb 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -95,7 +95,7 @@ This design also enables **multi-agent support** with flexible provider selectio ], "agents": { "defaults": { - "model": "gpt-5.4" + "model_name": "gpt-5.4" } } } @@ -268,13 +268,13 @@ The old `providers` configuration is **deprecated** but still supported for back ], "agents": { "defaults": { - "model": "glm-4.7" + "model_name": "glm-4.7" } } } ``` -For detailed migration guide, see [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). +For detailed migration guide, see [migration/model-list-migration.md](migration/model-list-migration.md). ### Provider Architecture @@ -300,7 +300,7 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", + "model_name": "glm-4.7", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 @@ -330,12 +330,11 @@ picoclaw agent -m "Hello" { "agents": { "defaults": { - "model": "anthropic/claude-opus-4-5" + "model_name": "anthropic/claude-opus-4-5" } }, "session": { - "dm_scope": "per-channel-peer", - "backlog_limit": 20 + "dm_scope": "per-channel-peer" }, "providers": { "openrouter": { diff --git a/docs/pt-br/ANTIGRAVITY_AUTH.md b/docs/pt-br/ANTIGRAVITY_AUTH.md new file mode 100644 index 000000000..d243783cb --- /dev/null +++ b/docs/pt-br/ANTIGRAVITY_AUTH.md @@ -0,0 +1,809 @@ +> Voltar ao [README](../../README.pt-br.md) + +# Guia de Autenticação e Integração do Antigravity + +## Visão Geral + +**Antigravity** (Google Cloud Code Assist) é um provedor de modelos de IA apoiado pelo Google que oferece acesso a modelos como Claude Opus 4.6 e Gemini através da infraestrutura de nuvem do Google. Este documento fornece um guia completo sobre como a autenticação funciona, como buscar modelos e como implementar um novo provedor no PicoClaw. + +--- + +## Índice + +1. [Fluxo de Autenticação](#fluxo-de-autenticação) +2. [Detalhes da Implementação OAuth](#detalhes-da-implementação-oauth) +3. [Gerenciamento de Tokens](#gerenciamento-de-tokens) +4. [Busca da Lista de Modelos](#busca-da-lista-de-modelos) +5. [Rastreamento de Uso](#rastreamento-de-uso) +6. [Estrutura do Plugin do Provedor](#estrutura-do-plugin-do-provedor) +7. [Requisitos de Integração](#requisitos-de-integração) +8. [Endpoints da API](#endpoints-da-api) +9. [Configuração](#configuração) +10. [Criando um Novo Provedor no PicoClaw](#criando-um-novo-provedor-no-picoclaw) + +--- + +## Fluxo de Autenticação + +### 1. OAuth 2.0 com PKCE + +O Antigravity utiliza **OAuth 2.0 com PKCE (Proof Key for Code Exchange)** para autenticação segura: + +``` +┌─────────────┐ ┌─────────────────┐ +│ Client │ ───(1) Generate PKCE Pair────────> │ │ +│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │ +│ │ │ Server │ +│ │ <──(3) Redirect with Code───────── │ │ +│ │ └─────────────────┘ +│ │ ───(4) Exchange Code for Tokens──> │ Token URL │ +│ │ │ │ +│ │ <──(5) Access + Refresh Tokens──── │ │ +└─────────────┘ └─────────────────┘ +``` + +### 2. Etapas Detalhadas + +#### Etapa 1: Gerar Parâmetros PKCE +```typescript +function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} +``` + +#### Etapa 2: Construir a URL de Autorização +```typescript +const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const REDIRECT_URI = "http://localhost:51121/oauth-callback"; + +function buildAuthUrl(params: { challenge: string; state: string }): string { + const url = new URL(AUTH_URL); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", SCOPES.join(" ")); + url.searchParams.set("code_challenge", params.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", params.state); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + return url.toString(); +} +``` + +**Escopos Necessários:** +```typescript +const SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", +]; +``` + +#### Etapa 3: Tratar o Callback OAuth + +**Modo Automático (Desenvolvimento Local):** +- Iniciar um servidor HTTP local na porta 51121 +- Aguardar o redirecionamento do Google +- Extrair o código de autorização dos parâmetros da query + +**Modo Manual (Remoto/Sem Interface Gráfica):** +- Exibir a URL de autorização para o usuário +- O usuário completa a autenticação no navegador +- O usuário cola a URL de redirecionamento completa no terminal +- Analisar o código da URL colada + +#### Etapa 4: Trocar o Código por Tokens +```typescript +const TOKEN_URL = "https://oauth2.googleapis.com/token"; + +async function exchangeCode(params: { + code: string; + verifier: string; +}): Promise<{ access: string; refresh: string; expires: number }> { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: params.code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + code_verifier: params.verifier, + }), + }); + + const data = await response.json(); + + return { + access: data.access_token, + refresh: data.refresh_token, + expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer + }; +} +``` + +#### Etapa 5: Buscar Dados Adicionais do Usuário + +**E-mail do Usuário:** +```typescript +async function fetchUserEmail(accessToken: string): Promise { + const response = await fetch( + "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + const data = await response.json(); + return data.email; +} +``` + +**ID do Projeto (Necessário para chamadas de API):** +```typescript +async function fetchProjectId(accessToken: string): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": JSON.stringify({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }), + }; + + const response = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers, + body: JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + const data = await response.json(); + return data.cloudaicompanionProject || "rising-fact-p41fc"; // Valor padrão de fallback +} +``` + +--- + +## Detalhes da Implementação OAuth + +### Credenciais do Cliente + +**Importante:** Estas são codificadas em base64 no código-fonte para sincronização com pi-ai: + +```typescript +const decode = (s: string) => Buffer.from(s, "base64").toString(); + +const CLIENT_ID = decode( + "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==" +); +const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); +``` + +### Modos do Fluxo OAuth + +1. **Fluxo Automático** (máquinas locais com navegador): + - Abre o navegador automaticamente + - O servidor de callback local captura o redirecionamento + - Nenhuma interação do usuário necessária após a autenticação inicial + +2. **Fluxo Manual** (remoto/sem interface/WSL2): + - URL exibida para copiar e colar manualmente + - O usuário completa a autenticação em um navegador externo + - O usuário cola a URL de redirecionamento completa de volta + +```typescript +function shouldUseManualOAuthFlow(isRemote: boolean): boolean { + return isRemote || isWSL2Sync(); +} +``` + +--- + +## Gerenciamento de Tokens + +### Estrutura do Perfil de Autenticação + +```typescript +type OAuthCredential = { + type: "oauth"; + provider: "google-antigravity"; + access: string; // Token de acesso + refresh: string; // Token de atualização + expires: number; // Timestamp de expiração (ms desde epoch) + email?: string; // E-mail do usuário + projectId?: string; // ID do projeto Google Cloud +}; +``` + +### Atualização de Tokens + +A credencial inclui um token de atualização que pode ser usado para obter novos tokens de acesso quando o atual expira. A expiração é definida com um buffer de 5 minutos para evitar condições de corrida. + +--- + +## Busca da Lista de Modelos + +### Buscar Modelos Disponíveis + +```typescript +const BASE_URL = "https://cloudcode-pa.googleapis.com"; + +async function fetchAvailableModels( + accessToken: string, + projectId: string +): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + }; + + const response = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers, + body: JSON.stringify({ project: projectId }), + } + ); + + const data = await response.json(); + + // Retorna modelos com informações de cota + return Object.entries(data.models).map(([modelId, modelInfo]) => ({ + id: modelId, + displayName: modelInfo.displayName, + quotaInfo: { + remainingFraction: modelInfo.quotaInfo?.remainingFraction, + resetTime: modelInfo.quotaInfo?.resetTime, + isExhausted: modelInfo.quotaInfo?.isExhausted, + }, + })); +} +``` + +### Formato da Resposta + +```typescript +type FetchAvailableModelsResponse = { + models?: Record; +}; +``` + +--- + +## Rastreamento de Uso + +### Buscar Dados de Uso + +```typescript +export async function fetchAntigravityUsage( + token: string, + timeoutMs: number +): Promise { + // 1. Buscar créditos e informações do plano + const loadCodeAssistRes = await fetch( + `${BASE_URL}/v1internal:loadCodeAssist`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: { + ideType: "ANTIGRAVITY", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + // Extrair informações de créditos + const { availablePromptCredits, planInfo, currentTier } = data; + + // 2. Buscar cotas dos modelos + const modelsRes = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ project: projectId }), + } + ); + + // Construir janelas de uso + return { + provider: "google-antigravity", + displayName: "Google Antigravity", + windows: [ + { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) }, + // Cotas individuais dos modelos... + ], + plan: currentTier?.name || planType, + }; +} +``` + +### Estrutura da Resposta de Uso + +```typescript +type ProviderUsageSnapshot = { + provider: "google-antigravity"; + displayName: string; + windows: UsageWindow[]; + plan?: string; + error?: string; +}; + +type UsageWindow = { + label: string; // "Credits" ou ID do modelo + usedPercent: number; // 0-100 + resetAt?: number; // Timestamp de quando a cota é redefinida +}; +``` + +--- + +## Estrutura do Plugin do Provedor + +### Definição do Plugin + +```typescript +const antigravityPlugin = { + id: "google-antigravity-auth", + name: "Google Antigravity Auth", + description: "OAuth flow for Google Antigravity (Cloud Code Assist)", + configSchema: emptyPluginConfigSchema(), + + register(api: PicoClawPluginApi) { + api.registerProvider({ + id: "google-antigravity", + label: "Google Antigravity", + docsPath: "/providers/models", + aliases: ["antigravity"], + + auth: [ + { + id: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + kind: "oauth", + run: async (ctx: ProviderAuthContext) => { + // Implementação OAuth aqui + }, + }, + ], + }); + }, +}; +``` + +### ProviderAuthContext + +```typescript +type ProviderAuthContext = { + config: PicoClawConfig; + agentDir?: string; + workspaceDir?: string; + prompter: WizardPrompter; // Prompts/notificações da UI + runtime: RuntimeEnv; // Logging, etc. + isRemote: boolean; // Se está executando remotamente + openUrl: (url: string) => Promise; // Abridor de navegador + oauth: { + createVpsAwareHandlers: Function; + }; +}; +``` + +### ProviderAuthResult + +```typescript +type ProviderAuthResult = { + profiles: Array<{ + profileId: string; + credential: AuthProfileCredential; + }>; + configPatch?: Partial; + defaultModel?: string; + notes?: string[]; +}; +``` + +--- + +## Requisitos de Integração + +### 1. Ambiente/Dependências Necessários + +- Go ≥ 1.25 +- Base de código do PicoClaw (`pkg/providers/` e `pkg/auth/`) +- Pacotes da biblioteca padrão `crypto` e `net/http` + +### 2. Cabeçalhos Necessários para Chamadas de API + +```typescript +const REQUIRED_HEADERS = { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", // ou "google-api-nodejs-client/9.15.1" + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", +}; + +// Para chamadas loadCodeAssist, incluir também: +const CLIENT_METADATA = { + ideType: "ANTIGRAVITY", // ou "IDE_UNSPECIFIED" + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", +}; +``` + +### 3. Sanitização de Schemas de Modelos + +O Antigravity usa modelos compatíveis com Gemini, então os schemas de ferramentas devem ser sanitizados: + +```typescript +const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([ + "patternProperties", + "additionalProperties", + "$schema", + "$id", + "$ref", + "$defs", + "definitions", + "examples", + "minLength", + "maxLength", + "minimum", + "maximum", + "multipleOf", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems", + "minProperties", + "maxProperties", +]); + +// Limpar schema antes de enviar +function cleanToolSchemaForGemini(schema: Record): unknown { + // Remover palavras-chave não suportadas + // Garantir que o nível superior tenha type: "object" + // Achatar uniões anyOf/oneOf +} +``` + +### 4. Tratamento de Blocos de Pensamento (Modelos Claude) + +Para modelos Claude via Antigravity, os blocos de pensamento requerem tratamento especial: + +```typescript +const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +export function sanitizeAntigravityThinkingBlocks( + messages: AgentMessage[] +): AgentMessage[] { + // Validar assinaturas de pensamento + // Normalizar campos de assinatura + // Descartar blocos de pensamento não assinados +} +``` + +--- + +## Endpoints da API + +### Endpoints de Autenticação + +| Endpoint | Método | Finalidade | +|----------|--------|-----------| +| `https://accounts.google.com/o/oauth2/v2/auth` | GET | Autorização OAuth | +| `https://oauth2.googleapis.com/token` | POST | Troca de tokens | +| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | Informações do usuário (e-mail) | + +### Endpoints do Cloud Code Assist + +| Endpoint | Método | Finalidade | +|----------|--------|-----------| +| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | Carregar informações do projeto, créditos, plano | +| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | Listar modelos disponíveis com cotas | +| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | Endpoint de streaming de chat | + +**Formato de Requisição da API (Chat):** +O endpoint `v1internal:streamGenerateContent` espera um envelope encapsulando a requisição Gemini padrão: + +```json +{ + "project": "your-project-id", + "model": "model-id", + "request": { + "contents": [...], + "systemInstruction": {...}, + "generationConfig": {...}, + "tools": [...] + }, + "requestType": "agent", + "userAgent": "antigravity", + "requestId": "agent-timestamp-random" +} +``` + +**Formato de Resposta da API (SSE):** +Cada mensagem SSE (`data: {...}`) é encapsulada em um campo `response`: + +```json +{ + "response": { + "candidates": [...], + "usageMetadata": {...}, + "modelVersion": "...", + "responseId": "..." + }, + "traceId": "...", + "metadata": {} +} +``` + +--- + +## Configuração + +### Configuração do config.json + +```json +{ + "model_list": [ + { + "model_name": "gemini-flash", + "model": "antigravity/gemini-3-flash", + "auth_method": "oauth" + } + ], + "agents": { + "defaults": { + "model_name": "gemini-flash" + } + } +} +``` + +### Armazenamento do Perfil de Autenticação + +Os perfis de autenticação são armazenados em `~/.picoclaw/auth.json`: + +```json +{ + "credentials": { + "google-antigravity": { + "access_token": "ya29...", + "refresh_token": "1//...", + "expires_at": "2026-01-01T00:00:00Z", + "provider": "google-antigravity", + "auth_method": "oauth", + "email": "user@example.com", + "project_id": "my-project-id" + } + } +} +``` + +--- + +## Criando um Novo Provedor no PicoClaw + +Os provedores do PicoClaw são implementados como pacotes Go em `pkg/providers/`. Para adicionar um novo provedor: + +### Implementação Passo a Passo + +#### 1. Criar o Arquivo do Provedor + +Crie um novo arquivo Go em `pkg/providers/`: + +``` +pkg/providers/ +└── your_provider.go +``` + +#### 2. Implementar a Interface Provider + +Seu provedor deve implementar a interface `Provider` definida em `pkg/providers/types.go`: + +```go +package providers + +type YourProvider struct { + apiKey string + apiBase string +} + +func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider { + if apiBase == "" { + apiBase = "https://api.your-provider.com/v1" + } + return &YourProvider{apiKey: apiKey, apiBase: apiBase} +} + +func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error { + // Implementar conclusão de chat com streaming +} +``` + +#### 3. Registrar na Factory + +Adicione seu provedor ao switch de protocolo em `pkg/providers/factory.go`: + +```go +case "your-provider": + return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil +``` + +#### 4. Adicionar Configuração Padrão (Opcional) + +Adicione uma entrada padrão em `pkg/config/defaults.go`: + +```go +{ + ModelName: "your-model", + Model: "your-provider/model-name", + APIKey: "", +}, +``` + +#### 5. Adicionar Suporte de Autenticação (Opcional) + +Se seu provedor requer OAuth ou autenticação especial, adicione um caso em `cmd/picoclaw/internal/auth/helpers.go`: + +```go +case "your-provider": + authLoginYourProvider() +``` + +#### 6. Configurar via `config.json` + +```json +{ + "model_list": [ + { + "model_name": "your-model", + "model": "your-provider/model-name", + "api_key": "your-api-key", + "api_base": "https://api.your-provider.com/v1" + } + ] +} +``` + +--- + +## Testando Sua Implementação + +### Comandos CLI + +```bash +# Autenticar com um provedor +picoclaw auth login --provider your-provider + +# Listar modelos (para Antigravity) +picoclaw auth models + +# Iniciar o gateway +picoclaw gateway + +# Executar um agente com um modelo específico +picoclaw agent -m "Hello" --model your-model +``` + +### Variáveis de Ambiente para Testes + +```bash +# Substituir o modelo padrão +export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model + +# Substituir configurações do provedor +export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]' +``` + +--- + +## Referências + +- **Arquivos Fonte:** + - `pkg/providers/antigravity_provider.go` - Implementação do provedor Antigravity + - `pkg/auth/oauth.go` - Implementação do fluxo OAuth + - `pkg/auth/store.go` - Armazenamento de credenciais de autenticação (`~/.picoclaw/auth.json`) + - `pkg/providers/factory.go` - Factory de provedores e roteamento de protocolo + - `pkg/providers/types.go` - Definições da interface do provedor + - `cmd/picoclaw/internal/auth/helpers.go` - Comandos CLI de autenticação + +- **Documentação:** + - `docs/ANTIGRAVITY_USAGE.md` - Guia de uso do Antigravity + - `docs/migration/model-list-migration.md` - Guia de migração + +--- + +## Observações + +1. **Projeto Google Cloud:** O Antigravity requer que o Gemini for Google Cloud esteja habilitado no seu projeto Google Cloud +2. **Cotas:** Usa cotas do projeto Google Cloud (sem cobrança separada) +3. **Acesso a Modelos:** Os modelos disponíveis dependem da configuração do seu projeto Google Cloud +4. **Blocos de Pensamento:** Modelos Claude via Antigravity requerem tratamento especial de blocos de pensamento com assinaturas +5. **Sanitização de Schemas:** Os schemas de ferramentas devem ser sanitizados para remover palavras-chave JSON Schema não suportadas + +--- + +--- + +## Tratamento de Erros Comuns + +### 1. Limitação de Taxa (HTTP 429) + +O Antigravity retorna um erro 429 quando as cotas do projeto/modelo estão esgotadas. A resposta de erro frequentemente contém um `quotaResetDelay` no campo `details`. + +**Exemplo de Erro 429:** +```json +{ + "error": { + "code": 429, + "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.", + "status": "RESOURCE_EXHAUSTED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "metadata": { + "quotaResetDelay": "4h30m28.060903746s" + } + } + ] + } +} +``` + +### 2. Respostas Vazias (Modelos Restritos) + +Alguns modelos podem aparecer na lista de modelos disponíveis, mas retornar uma resposta vazia (200 OK mas stream SSE vazio). Isso geralmente acontece com modelos em preview ou restritos que o projeto atual não tem permissão para usar. + +**Tratamento:** Tratar respostas vazias como erros informando ao usuário que o modelo pode estar restrito ou inválido para seu projeto. + +--- + +## Solução de Problemas + +### "Token expired" (token expirado) +- Atualizar tokens OAuth: `picoclaw auth login --provider antigravity` + +### "Gemini for Google Cloud is not enabled" (Gemini for Google Cloud não está habilitado) +- Habilitar a API no seu Google Cloud Console + +### "Project not found" (projeto não encontrado) +- Verificar se seu projeto Google Cloud tem as APIs necessárias habilitadas +- Verificar se o ID do projeto foi obtido corretamente durante a autenticação + +### Modelos não aparecem na lista +- Verificar se a autenticação OAuth foi concluída com sucesso +- Verificar o armazenamento do perfil de autenticação: `~/.picoclaw/auth.json` +- Executar novamente `picoclaw auth login --provider antigravity` diff --git a/docs/pt-br/ANTIGRAVITY_USAGE.md b/docs/pt-br/ANTIGRAVITY_USAGE.md new file mode 100644 index 000000000..d4b681ad0 --- /dev/null +++ b/docs/pt-br/ANTIGRAVITY_USAGE.md @@ -0,0 +1,72 @@ +> Voltar ao [README](../../README.pt-br.md) + +# Usando o provedor Antigravity no PicoClaw + +Este guia explica como configurar e usar o provedor **Antigravity** (Google Cloud Code Assist) no PicoClaw. + +## Pré-requisitos + +1. Uma conta Google. +2. Google Cloud Code Assist habilitado (geralmente disponível através da integração "Gemini for Google Cloud"). + +## 1. Autenticação + +Para se autenticar com o Antigravity, execute o seguinte comando: + +```bash +picoclaw auth login --provider antigravity +``` + +### Autenticação manual (Headless/VPS) +Se você está executando em um servidor (Coolify/Docker) e não consegue acessar `localhost`, siga estas etapas: +1. Execute o comando acima. +2. Copie a URL fornecida e abra-a no seu navegador local. +3. Complete o login. +4. Seu navegador será redirecionado para uma URL `localhost:51121` (que não carregará). +5. **Copie essa URL final** da barra de endereços do seu navegador. +6. **Cole-a de volta no terminal** onde o PicoClaw está aguardando. + +O PicoClaw extrairá automaticamente o código de autorização e completará o processo. + +## 2. Gerenciando modelos + +### Listar modelos disponíveis +Para ver quais modelos seu projeto tem acesso e verificar suas cotas: + +```bash +picoclaw auth models +``` + +### Trocar de modelo +Você pode alterar o modelo padrão em `~/.picoclaw/config.json` ou substituí-lo via CLI: + +```bash +# Substituir para um único comando +picoclaw agent -m "Hello" --model claude-opus-4-6-thinking +``` + +## 3. Uso em produção (Coolify/Docker) + +Se você está implantando via Coolify ou Docker, siga estas etapas para testar: + +1. **Variáveis de ambiente**: + * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash` +2. **Persistência da autenticação**: + Se você já fez login localmente, pode copiar suas credenciais para o servidor: + ```bash + scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/ + ``` + *Alternativamente*, execute o comando `auth login` uma vez no servidor se você tiver acesso ao terminal. + +## 4. Solução de problemas + +* **Resposta vazia**: Se um modelo retorna uma resposta vazia, ele pode estar restrito para o seu projeto. Tente `gemini-3-flash` ou `claude-opus-4-6-thinking`. +* **429 Limite de taxa**: O Antigravity possui cotas rigorosas. O PicoClaw exibirá o "tempo de redefinição" na mensagem de erro se você atingir um limite. +* **404 Não encontrado**: Certifique-se de que está usando um ID de modelo da lista `picoclaw auth models`. Use o ID curto (ex.: `gemini-3-flash`) e não o caminho completo. + +## 5. Resumo dos modelos funcionais + +Com base nos testes, os seguintes modelos são os mais confiáveis: +* `gemini-3-flash` (Rápido, alta disponibilidade) +* `gemini-2.5-flash-lite` (Leve) +* `claude-opus-4-6-thinking` (Poderoso, inclui raciocínio) diff --git a/docs/pt-br/chat-apps.md b/docs/pt-br/chat-apps.md index 5f18080f0..08ef292fa 100644 --- a/docs/pt-br/chat-apps.md +++ b/docs/pt-br/chat-apps.md @@ -8,22 +8,22 @@ Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, D > **Nota**: Todos os canais baseados em webhook (LINE, WeCom, etc.) são servidos em um único servidor HTTP Gateway compartilhado (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). Não há portas por canal para configurar. Nota: Feishu usa o modo WebSocket/SDK e não utiliza o servidor HTTP webhook compartilhado. -| Channel | Setup | -| ------------ | ---------------------------------- | -| **Telegram** | Easy (just a token) | -| **Discord** | Easy (bot token + intents) | -| **WhatsApp** | Easy (native: QR scan; or bridge URL) | -| **Matrix** | Medium (homeserver + bot access token) | -| **QQ** | Easy (AppID + AppSecret) | -| **DingTalk** | Medium (app credentials) | -| **LINE** | Medium (credentials + webhook URL) | -| **WeCom AI Bot** | Medium (Token + AES key) | -| **Feishu** | Medium (App ID + Secret, WebSocket mode) | -| **Slack** | Medium (Bot token + App token) | -| **IRC** | Medium (server + TLS config) | -| **OneBot** | Medium (QQ via OneBot protocol) | -| **MaixCam** | Easy (Sipeed hardware integration) | -| **Pico** | Native PicoClaw protocol | +| Canal | Dificuldade | Descrição | Documentação | +| -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| **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) | +| **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) | +| **DingTalk** | ⭐⭐ Médio | Modo Stream (sem IP público), empresarial | [Documentação](../channels/dingtalk/README.pt-br.md) | +| **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 | - | +| **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) @@ -168,12 +168,13 @@ Se `session_store_path` estiver vazio, a sessão é armazenada em `/w
QQ -**1. Criar um bot** +**Configuração rápida (recomendada)** -- Acesse a [QQ Open Platform](https://q.qq.com/#) -- Crie um aplicativo → Obtenha **AppID** e **AppSecret** +A QQ Open Platform oferece uma página de configuração com um clique para bots compatíveis com OpenClaw: -**2. Configurar** +1. Abra o [QQ Bot Quick Start](https://q.qq.com/qqbot/openclaw/index.html) e escaneie o QR code para fazer login +2. Um bot é criado automaticamente — copie o **App ID** e o **App Secret** +3. Configure o PicoClaw: ```json { @@ -188,13 +189,20 @@ Se `session_store_path` estiver vazio, a sessão é armazenada em `/w } ``` -> Defina `allow_from` como vazio para permitir todos os usuários, ou especifique números QQ para restringir o acesso. +4. Execute `picoclaw gateway` e abra o QQ para conversar com seu bot -**3. Executar** +> O App Secret é exibido apenas uma vez. Salve-o imediatamente — visualizá-lo novamente forçará uma redefinição. +> +> Bots criados pela página de configuração rápida são inicialmente apenas para o criador e não suportam chats de grupo. Para habilitar o acesso em grupo, configure o modo sandbox na [QQ Open Platform](https://q.qq.com/). -```bash -picoclaw gateway -``` +**Configuração manual** + +Se preferir criar o bot manualmente: + +* Faça login na [QQ Open Platform](https://q.qq.com/) para se registrar como desenvolvedor +* Crie um bot QQ — personalize seu avatar e nome +* Copie o **App ID** e o **App Secret** nas configurações do bot +* Configure conforme mostrado acima e execute `picoclaw gateway`
@@ -229,8 +237,31 @@ picoclaw gateway ```bash picoclaw gateway ``` +
+
+MaixCam + +Canal de integração projetado especificamente para hardware de câmera AI Sipeed. + +```json +{ + "channels": { + "maixcam": { + "enabled": true + } + } +} +``` + +```bash +picoclaw gateway +``` + +
+ +
Matrix @@ -261,7 +292,7 @@ picoclaw gateway picoclaw gateway ``` -Para opções completas (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), veja o [Guia de Configuração do Canal Matrix](docs/channels/matrix/README.md). +Para opções completas (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), veja o [Guia de Configuração do Canal Matrix](../channels/matrix/README.md).
@@ -322,7 +353,7 @@ O PicoClaw suporta três tipos de integração WeCom: **Opção 2: WeCom App (App Personalizado)** - Mais recursos, mensagens proativas, apenas chat privado **Opção 3: WeCom AI Bot (AI Bot)** - AI Bot oficial, respostas em streaming, suporta chat de grupo e privado -Veja o [Guia de Configuração do WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) para instruções detalhadas de configuração. +Veja o [Guia de Configuração do WeCom AI Bot](../channels/wecom/wecom_aibot/README.pt-br.md) para instruções detalhadas de configuração. **Configuração Rápida - WeCom Bot:** @@ -396,7 +427,7 @@ picoclaw gateway **1. Criar um AI Bot** * Acesse o Console de Administração WeCom → Gerenciamento de Apps → AI Bot -* Nas configurações do AI Bot, configure a URL de callback: `http://your-server:18791/webhook/wecom-aibot` +* Nas configurações do AI Bot, configure a URL de callback: `http://your-server:18790/webhook/wecom-aibot` * Copie o **Token** e clique em "Gerar Aleatoriamente" para o **EncodingAESKey** **2. Configurar** @@ -425,3 +456,169 @@ picoclaw gateway > **Nota**: O WeCom AI Bot usa protocolo de streaming pull — sem preocupações com timeout de resposta. Tarefas longas (>30 segundos) mudam automaticamente para entrega via `response_url` push.
+ +
+Feishu (Lark) + +O PicoClaw se conecta ao Feishu via modo WebSocket/SDK — não é necessário URL de webhook público nem servidor de callback. + +**1. Criar um aplicativo** + +* Acesse a [Feishu Open Platform](https://open.feishu.cn/) e crie um aplicativo +* Nas configurações do aplicativo, habilite a capacidade **Bot** +* Crie uma versão e publique o aplicativo (o aplicativo deve ser publicado para funcionar) +* Copie o **App ID** (começa com `cli_`) e o **App Secret** + +**2. Configurar** + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "app_id": "cli_xxx", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +Opcional: `encrypt_key` e `verification_token` para criptografia de eventos (recomendado para produção). + +**3. Executar e conversar** + +```bash +picoclaw gateway +``` + +Abra o Feishu, pesquise o nome do seu bot e comece a conversar. Você também pode adicionar o bot a um grupo — use `group_trigger.mention_only: true` para responder apenas quando @mencionado. + +Para opções completas, veja o [Guia de Configuração do Canal Feishu](../channels/feishu/README.pt-br.md). + +
+ +
+Slack + +**1. Criar um aplicativo Slack** + +* Acesse a [Slack API](https://api.slack.com/apps) e crie um novo aplicativo +* Em **OAuth & Permissions**, adicione os escopos do bot: `chat:write`, `app_mentions:read`, `im:history`, `im:read`, `im:write` +* Instale o aplicativo no seu workspace +* Copie o **Bot Token** (`xoxb-...`) e o **App-Level Token** (`xapp-...`, habilite Socket Mode para obtê-lo) + +**2. Configurar** + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "xoxb-YOUR-BOT-TOKEN", + "app_token": "xapp-YOUR-APP-TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+IRC + +**1. Configurar** + +```json +{ + "channels": { + "irc": { + "enabled": true, + "server": "irc.libera.chat:6697", + "tls": true, + "nick": "picoclaw-bot", + "channels": ["#your-channel"], + "password": "", + "allow_from": [] + } + } +} +``` + +Opcional: `nickserv_password` para autenticação NickServ, `sasl_user`/`sasl_password` para autenticação SASL. + +**2. Executar** + +```bash +picoclaw gateway +``` + +O bot se conectará ao servidor IRC e entrará nos canais especificados. + +
+ +
+OneBot (QQ via protocolo OneBot) + +OneBot é um protocolo aberto para bots QQ. O PicoClaw se conecta a qualquer implementação compatível com OneBot v11 (ex.: [Lagrange](https://github.com/LagrangeDev/Lagrange.Core), [NapCat](https://github.com/NapNeko/NapCatQQ)) via WebSocket. + +**1. Configurar uma implementação OneBot** + +Instale e execute um framework de bot QQ compatível com OneBot v11. Habilite seu servidor WebSocket. + +**2. Configurar** + +```json +{ + "channels": { + "onebot": { + "enabled": true, + "ws_url": "ws://127.0.0.1:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| Campo | Descrição | +|-------|-----------| +| `ws_url` | URL WebSocket da implementação OneBot | +| `access_token` | Token de acesso para autenticação (se configurado no OneBot) | +| `reconnect_interval` | Intervalo de reconexão em segundos (padrão: 5) | + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+MaixCam + +Canal de integração projetado especificamente para hardware de câmera AI Sipeed. + +```json +{ + "channels": { + "maixcam": { + "enabled": true + } + } +} +``` + +```bash +picoclaw gateway +``` + +
diff --git a/docs/pt-br/configuration.md b/docs/pt-br/configuration.md index e7e2c7ec0..ee14ca724 100644 --- a/docs/pt-br/configuration.md +++ b/docs/pt-br/configuration.md @@ -57,7 +57,7 @@ Por padrão, as skills são carregadas de: 1. `~/.picoclaw/workspace/skills` (workspace) 2. `~/.picoclaw/skills` (global) -3. `/skills` (builtin) +3. `/skills` (embutido) Para configurações avançadas/de teste, você pode substituir o diretório raiz de skills builtin com: diff --git a/docs/pt-br/credential_encryption.md b/docs/pt-br/credential_encryption.md new file mode 100644 index 000000000..59a31e438 --- /dev/null +++ b/docs/pt-br/credential_encryption.md @@ -0,0 +1,159 @@ +> Voltar ao [README](../../README.pt-br.md) + +# Criptografia de Credenciais + +O PicoClaw suporta a criptografia de valores `api_key` nas entradas de configuração `model_list`. +As chaves criptografadas são armazenadas como strings `enc://` e descriptografadas automaticamente na inicialização. + +--- + +## Início Rápido + +**1. Defina sua frase secreta** + +```bash +export PICOCLAW_KEY_PASSPHRASE="your-passphrase" +``` + +**2. Criptografe uma chave de API** + +Execute `picoclaw onboard` — ele solicita sua frase secreta e gera a chave SSH, +depois recriptografa automaticamente quaisquer entradas `api_key` em texto simples na sua configuração +na próxima chamada `SaveConfig`. O valor `enc://` resultante será semelhante a: + +``` +enc://AAAA...base64... +``` + +**3. Cole a saída na sua configuração** + +```json +{ + "model_list": [ + { + "model_name": "gpt-4o", + "model": "openai/gpt-4o", + "api_key": "enc://AAAA...base64...", + "api_base": "https://api.openai.com/v1" + } + ] +} +``` + +--- + +## Formatos de `api_key` Suportados + +| Formato | Exemplo | Comportamento | +|---------|---------|---------------| +| Texto simples | `sk-abc123` | Usado como está | +| Referência de arquivo | `file://openai.key` | Conteúdo lido do mesmo diretório do arquivo de configuração | +| Criptografado | `enc://` | Descriptografado na inicialização usando `PICOCLAW_KEY_PASSPHRASE` | +| Vazio | `""` | Passado sem alteração (usado com `auth_method: oauth`) | + +--- + +## Design Criptográfico + +### Derivação de Chave + +A criptografia utiliza **HKDF-SHA256** com uma chave privada SSH como segundo fator. + +``` +sshHash = SHA256(ssh_private_key_file_bytes) +ikm = HMAC-SHA256(key=sshHash, message=passphrase) +aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +``` + +### Criptografia + +``` +AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key) +``` + +### Formato de Transmissão + +``` +enc:// +``` + +| Campo | Tamanho | Descrição | +|-------|---------|-----------| +| `salt` | 16 bytes | Aleatório por criptografia; alimentado no HKDF | +| `nonce` | 12 bytes | Aleatório por criptografia; IV do AES-GCM | +| `ciphertext` | variável | Texto cifrado AES-256-GCM + tag de autenticação de 16 bytes | + +O tag de autenticação GCM é anexado automaticamente ao texto cifrado. Qualquer adulteração faz com que a descriptografia falhe com um erro em vez de retornar texto simples corrompido. + +### Desempenho + +| Operação | Tempo (ARM Cortex-A) | +|----------|----------------------| +| Derivação de chave (HKDF) | < 1 ms | +| Descriptografia AES-256-GCM | < 1 ms | +| **Sobrecarga total na inicialização** | **< 2 ms por chave** | + +--- + +## Segurança de Dois Fatores com Chave SSH + +Quando uma chave privada SSH é fornecida, quebrar a criptografia requer **ambos**: + +1. A **frase secreta** (`PICOCLAW_KEY_PASSPHRASE`) +2. O **arquivo de chave privada SSH** + +Isso significa que um arquivo de configuração vazado sozinho não é suficiente para recuperar a chave de API, mesmo que a frase secreta seja fraca. A chave SSH contribui com 256 bits de entropia (Ed25519) independentemente da força da frase secreta. + +### Modelo de Ameaça + +| O que o atacante possui | Pode descriptografar? | +|------------------------|----------------------| +| Apenas o arquivo de configuração | Não — necessita da frase secreta + chave SSH | +| Apenas a chave SSH | Não — necessita da frase secreta | +| Apenas a frase secreta | Não — necessita da chave SSH | +| Arquivo de configuração + chave SSH + frase secreta | Sim — comprometimento total | + +--- + +## Variáveis de Ambiente + +| Variável | Obrigatório | Descrição | +|----------|-------------|-----------| +| `PICOCLAW_KEY_PASSPHRASE` | Sim (para `enc://`) | Frase secreta usada para derivação de chave | +| `PICOCLAW_SSH_KEY_PATH` | Não | Caminho para a chave privada SSH. Se não definido, detecta automaticamente em `~/.ssh/picoclaw_ed25519.key` | + +### Detecção Automática da Chave SSH + +Se `PICOCLAW_SSH_KEY_PATH` não estiver definido, o PicoClaw procura a chave dedicada: + +``` +~/.ssh/picoclaw_ed25519.key +``` + +Este arquivo dedicado evita conflitos com as chaves SSH existentes do usuário. +Execute `picoclaw onboard` para gerá-lo automaticamente. + +`os.UserHomeDir()` é usado para resolução multiplataforma do diretório home (lê `USERPROFILE` no Windows, `HOME` no Unix/macOS). + +> **Nota:** Um arquivo de chave SSH é obrigatório para a criptografia de credenciais. Se nenhuma chave for encontrada e `PICOCLAW_SSH_KEY_PATH` não estiver definido, a criptografia/descriptografia falhará. Execute `picoclaw onboard` para gerar a chave automaticamente. + +--- + +## Migração + +Como os únicos materiais secretos são `PICOCLAW_KEY_PASSPHRASE` e o arquivo de chave privada SSH, a migração é simples: + +1. Copie o arquivo de configuração para a nova máquina. +2. Defina `PICOCLAW_KEY_PASSPHRASE` com o mesmo valor. +3. Copie o arquivo de chave privada SSH para o mesmo caminho (ou defina `PICOCLAW_SSH_KEY_PATH` para sua nova localização). + +Nenhuma recriptografia é necessária. + +--- + +## Considerações de Segurança + +- **Tanto a frase secreta quanto a chave SSH são obrigatórias.** A chave SSH atua como um segundo fator — sem ela, a criptografia/descriptografia falhará. Execute `picoclaw onboard` para gerar a chave se ela não existir. +- **A chave SSH é somente leitura em tempo de execução.** O PicoClaw nunca escreve ou modifica o arquivo de chave SSH. +- **Chaves em texto simples continuam sendo suportadas.** Configurações existentes sem `enc://` não são afetadas. +- **O formato `enc://` é versionado** através do campo `info` do HKDF (`picoclaw-credential-v1`), permitindo futuras atualizações de algoritmo sem quebrar valores criptografados existentes. diff --git a/docs/pt-br/debug.md b/docs/pt-br/debug.md new file mode 100644 index 000000000..8614cd5ed --- /dev/null +++ b/docs/pt-br/debug.md @@ -0,0 +1,36 @@ +# Depuração do PicoClaw + +> Voltar ao [README](../../README.pt-br.md) + +O PicoClaw realiza múltiplas interações complexas nos bastidores para cada requisição que recebe — desde o roteamento de mensagens e avaliação de complexidade, até a execução de ferramentas e adaptação a falhas de modelo. Poder ver exatamente o que está acontecendo é crucial, não apenas para solucionar problemas potenciais, mas também para realmente entender como o agente opera. + +## Iniciando o PicoClaw em modo de depuração + +Para obter informações detalhadas sobre o que o agente está fazendo (requisições LLM, chamadas de ferramentas, roteamento de mensagens), você pode iniciar o gateway do PicoClaw com a flag de depuração: + +```bash +picoclaw gateway --debug +# or +picoclaw gateway -d +``` + +Neste modo, o sistema formata os logs de forma detalhada e exibe prévias dos prompts do sistema e dos resultados de execução das ferramentas. + +## Desabilitando a truncagem de logs (logs completos) + +Por padrão, o PicoClaw trunca strings muito longas (como o *Prompt do Sistema* ou resultados JSON grandes) nos logs de depuração para manter o console legível. + +Se você precisar inspecionar a saída completa de um comando ou o payload exato enviado ao modelo LLM, pode usar a flag `--no-truncate`. + +**Nota:** Esta flag *só* funciona quando combinada com o modo `--debug`. + +```bash +picoclaw gateway --debug --no-truncate + +``` + +Quando esta flag está ativa, a função de truncagem global é desabilitada. Isso é extremamente útil para: + +* Verificar a sintaxe exata das mensagens enviadas ao provedor. +* Ler a saída completa de ferramentas como `exec`, `web_fetch` ou `read_file`. +* Depurar o histórico de sessão salvo na memória. diff --git a/docs/pt-br/docker.md b/docs/pt-br/docker.md index af58c89b2..bac48954b 100644 --- a/docs/pt-br/docker.md +++ b/docs/pt-br/docker.md @@ -12,6 +12,7 @@ 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 ambos ausentes) docker compose -f docker/docker-compose.yml --profile gateway up # O contêiner exibe "First-run setup complete." e para. diff --git a/docs/pt-br/hardware-compatibility.md b/docs/pt-br/hardware-compatibility.md new file mode 100644 index 000000000..771621014 --- /dev/null +++ b/docs/pt-br/hardware-compatibility.md @@ -0,0 +1,152 @@ +> Voltar ao [README](../../README.pt-br.md) + +# 🖥️ PicoClaw Lista de compatibilidade de hardware + +O PicoClaw roda em praticamente qualquer dispositivo Linux. Esta página registra chips, produtos e placas de desenvolvimento verificados. + +**Seu hardware não está na lista?** Envie um PR para adicioná-lo! Fabricantes de hardware são bem-vindos para contribuir e co-promover. + +--- + +## 1. Suporte a chips verificado + +### x86 + +| Fabricante | Chip | Notas | +|------------|------|-------| +| Intel | Any x86 CPU (i386+) | Todos os processadores desktop/servidor/notebook | +| AMD | Any x86 CPU | Todos os processadores desktop/servidor/notebook | + +### ARM + +| Sub-arq | Chips típicos | Notas | +|---------|---------------|-------| +| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | Single-core ARM1176JZF-S | +| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | Single-core Cortex-A7, usado no LicheePi Zero | +| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | Quad-core Cortex-A53, usado no Orange Pi Zero 3 | +| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | Quad-core Cortex-A72 | +| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | Quad-core Cortex-A76 | +| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | Dual-core Cortex-A53 + NPU, usado no NanoKVM-Pro / MaixCAM2 | + +### RISC-V (riscv64) + +| Fabricante | Chip | Núcleo | Notas | +|------------|------|--------|-------| +| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 integrado, usado no LicheeRV-Nano / NanoKVM / MaixCAM | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L integrado, 1 TOPS NPU, câmera AI 4K SiP | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | Série de câmeras AI RISC-V | +| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | Usado no HaaS506-LD1 RTU industrial | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | Usado no Milk-V Jupiter, BananaPi BPI-F3 | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | Compatível com RVA23, RVV de 1024 bits, inferência AI FP8 | +| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8 núcleos, 16MB cache L3, classe desktop | +| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU, usado no CanMV-K230 | + +### MIPS + +| Fabricante | Chip | Notas | +|------------|------|-------| +| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz, usado em muitos roteadores OpenWrt (ex. Xiaomi Router 3G) | + +### LoongArch (loong64) + +| Fabricante | Chip | Notas | +|------------|------|-------| +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | Quad-core LA464 @ 2.5GHz, desktop/estação de trabalho | +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | Quad-core 4C/8T @ 2.5GHz, IPC comparável ao Intel 10ª geração | +| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | Dual-core @ 1GHz, aplicações industriais/IoT | + +--- + +## 2. Produtos verificados (por data de lançamento) + +Produtos de consumo, roteadores e dispositivos industriais testados com o PicoClaw. + +| Ano | Produto | Arq | SoC | RAM | Categoria | +|-----|---------|-----|-----|-----|-----------| +| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | Smartphone | +| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | Tablet | +| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | Roteador (OpenWrt) | +| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | TV Box / Servidor doméstico | +| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | Alto-falante inteligente | +| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM | +| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | RTU industrial | +| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | IP-KVM Pro | +| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | Câmera AI 4K | + +--- + +## 3. Placas de desenvolvimento verificadas (por data de lançamento) + +| Ano | Placa | Arq | SoC | RAM | Link de compra | +|-----|-------|-----|-----|-----|----------------| +| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — | +| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — | +| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — | +| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — | +| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) | +| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) | +| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) | +| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) | +| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) | +| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) | +| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) | + +--- + +## 4. Também funciona em + +### Celulares Android (via Termux) + +Qualquer celular Android ARM64 (2015+) com 1GB+ de RAM. Instale o [Termux](https://github.com/termux/termux-app), use `proot` para rodar o PicoClaw. + +> Veja [README: Rodar em celulares Android antigos](../../README.pt-br.md#-run-on-old-android-phones) para instruções de configuração. + +### Desktop / Servidor / Nuvem + +| Plataforma | Notas | +|------------|-------| +| x86_64 Linux | Binário nativo, sem dependências | +| x86_64 Windows | Binário nativo | +| macOS (Intel / Apple Silicon) | Binário nativo | +| Docker (any platform) | `docker compose` em uma linha, veja [Guia Docker](docker.md) | +| OpenWrt routers | Builds MIPS/ARM, requer >32MB de RAM livre | +| FreeBSD / NetBSD | Builds x86_64 e arm64 disponíveis | + +--- + +## 5. Requisitos mínimos + +| Recurso | Mínimo | Recomendado | +|---------|--------|-------------| +| RAM | 10MB livres | 32MB+ livres | +| Armazenamento | 20MB (binário) | 50MB+ (com workspace) | +| CPU | Qualquer (single-core 0,6GHz+) | — | +| OS | Linux (kernel 3.x+) | Linux 5.x+ | +| Rede | Necessária (para chamadas de API LLM) | Ethernet ou WiFi | + +--- + +## 6. Como testar e contribuir + +```bash +# 1. Baixar para sua arquitetura +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz + +# 2. Inicializar +./picoclaw onboard + +# 3. Testar +./picoclaw agent -m "Hello, what board am I running on?" +``` + +Builds disponíveis: `linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle` + +### Adicionar seu hardware + +1. Faça fork deste repositório +2. Adicione seu chip / produto / placa na tabela apropriada +3. Inclua: nome, arquitetura, SoC, RAM, ano e um link se disponível +4. Envie um PR + +Fabricantes de hardware: deseja adicionar suporte oficial ou co-promover? Abra uma issue ou entre em contato via [Discord](https://discord.gg/V4sAZ9XWpN). diff --git a/docs/pt-br/providers.md b/docs/pt-br/providers.md index 04fb9fc6b..0f7a4b5a1 100644 --- a/docs/pt-br/providers.md +++ b/docs/pt-br/providers.md @@ -93,7 +93,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de ], "agents": { "defaults": { - "model": "gpt-5.4" + "model_name": "gpt-5.4" } } } @@ -266,13 +266,13 @@ A configuração antiga `providers` está **descontinuada** mas ainda é suporta ], "agents": { "defaults": { - "model": "glm-4.7" + "model_name": "glm-4.7" } } } ``` -Para guia de migração detalhado, veja [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). +Para guia de migração detalhado, veja [migration/model-list-migration.md](../migration/model-list-migration.md). ### Arquitetura de Provedores @@ -298,7 +298,7 @@ Isso mantém o runtime leve enquanto torna novos backends compatíveis com OpenA "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", + "model_name": "glm-4.7", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 @@ -328,12 +328,11 @@ picoclaw agent -m "Hello" { "agents": { "defaults": { - "model": "anthropic/claude-opus-4-5" + "model_name": "anthropic/claude-opus-4-5" } }, "session": { - "dm_scope": "per-channel-peer", - "backlog_limit": 20 + "dm_scope": "per-channel-peer" }, "providers": { "openrouter": { diff --git a/docs/pt-br/tools_configuration.md b/docs/pt-br/tools_configuration.md index b6f726aa4..2cc4f3999 100644 --- a/docs/pt-br/tools_configuration.md +++ b/docs/pt-br/tools_configuration.md @@ -70,9 +70,32 @@ A ferramenta exec é usada para executar comandos shell. | Config | Tipo | Padrão | Descrição | |------------------------|-------|--------|-------------------------------------------------| +| `enabled` | bool | true | Habilitar a ferramenta exec | | `enable_deny_patterns` | bool | true | Habilitar bloqueio padrão de comandos perigosos | | `custom_deny_patterns` | array | [] | Padrões de negação personalizados (expressões regulares) | +### Desabilitando a Ferramenta Exec + +Para desabilitar completamente a ferramenta `exec`, defina `enabled` como `false`: + +**Via arquivo de configuração:** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**Via variável de ambiente:** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **Nota:** Quando desabilitada, o agent não poderá executar comandos shell. Isso também afeta a capacidade da ferramenta Cron de executar comandos shell agendados. + ### Funcionalidade - **`enable_deny_patterns`**: Defina como `false` para desabilitar completamente os padrões de bloqueio de comandos perigosos padrão @@ -329,6 +352,7 @@ Todas as opções de configuração podem ser substituídas via variáveis de am Por exemplo: - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` diff --git a/docs/pt-br/troubleshooting.md b/docs/pt-br/troubleshooting.md index e6c1a55ab..286ad2ac8 100644 --- a/docs/pt-br/troubleshooting.md +++ b/docs/pt-br/troubleshooting.md @@ -16,7 +16,7 @@ **Correção:** Em `~/.picoclaw/config.json` (ou seu caminho de configuração): -1. **agents.defaults.model** deve corresponder a um `model_name` em `model_list` (ex.: `"openrouter-free"`). +1. **agents.defaults.model_name** deve corresponder a um `model_name` em `model_list` (ex.: `"openrouter-free"`). 2. O **model** dessa entrada deve ser um ID de modelo OpenRouter válido, por exemplo: - `"openrouter/free"` – nível gratuito automático - `"google/gemini-2.0-flash-exp:free"` @@ -28,7 +28,7 @@ Exemplo: { "agents": { "defaults": { - "model": "openrouter-free" + "model_name": "openrouter-free" } }, "model_list": [ diff --git a/docs/spawn-tasks.md b/docs/spawn-tasks.md index eff96ce45..05a5215d2 100644 --- a/docs/spawn-tasks.md +++ b/docs/spawn-tasks.md @@ -2,6 +2,15 @@ > Back to [README](../README.md) +PicoClaw supports **asynchronous task execution** via the `spawn` tool. This is primarily used by the **Heartbeat** system to run long-running tasks without blocking the main agent loop. + +## Heartbeat + +The heartbeat system periodically checks `workspace/HEARTBEAT.md` for scheduled tasks. On first run, a default template is auto-generated. You can customize it to define quick tasks (handled inline) and long tasks (delegated via `spawn`). + +**Example `HEARTBEAT.md`:** + +```markdown ## Quick Tasks (respond directly) - Report current time diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md index a38f0856f..d0160050d 100644 --- a/docs/tools_configuration.md +++ b/docs/tools_configuration.md @@ -41,11 +41,12 @@ General settings for fetching and processing webpage content. ### Brave -| Config | Type | Default | Description | -|---------------|--------|---------|---------------------------| -| `enabled` | bool | false | Enable Brave search | -| `api_key` | string | - | Brave Search API key | -| `max_results` | int | 5 | Maximum number of results | +| Config | Type | Default | Description | +|---------------|----------|---------|------------------------------------------------| +| `enabled` | bool | false | Enable Brave search | +| `api_key` | string | - | Brave Search API key | +| `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) | +| `max_results` | int | 5 | Maximum number of results | ### DuckDuckGo @@ -56,11 +57,46 @@ General settings for fetching and processing webpage content. ### Perplexity +| Config | Type | Default | Description | +|---------------|----------|---------|------------------------------------------------| +| `enabled` | bool | false | Enable Perplexity search | +| `api_key` | string | - | Perplexity API key | +| `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) | +| `max_results` | int | 5 | Maximum number of results | + +### Tavily + | Config | Type | Default | Description | |---------------|--------|---------|---------------------------| -| `enabled` | bool | false | Enable Perplexity search | -| `api_key` | string | - | Perplexity API key | -| `max_results` | int | 5 | Maximum number of results | +| `enabled` | bool | false | Enable Tavily search | +| `api_key` | string | - | Tavily API key | +| `base_url` | string | - | Custom Tavily API base URL | +| `max_results` | int | 0 | Maximum number of results (0 = default) | + +### SearXNG + +| Config | Type | Default | Description | +|---------------|--------|--------------------------|---------------------------| +| `enabled` | bool | false | Enable SearXNG search | +| `base_url` | string | `http://localhost:8888` | SearXNG instance URL | +| `max_results` | int | 5 | Maximum number of results | + +### GLM Search + +| Config | Type | Default | Description | +|-----------------|--------|------------------------------------------------------|---------------------------| +| `enabled` | bool | false | Enable GLM Search | +| `api_key` | string | - | GLM API key | +| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | GLM Search API URL | +| `search_engine` | string | `search_std` | Search engine type | +| `max_results` | int | 5 | Maximum number of results | + +### Additional Web Settings + +| Config | Type | Default | Description | +|--------------------------|----------|---------|----------------------------------------------------------------| +| `prefer_native` | bool | true | Prefer provider's native search over configured search engines | +| `private_host_whitelist` | string[] | `[]` | Private/internal hosts allowed for web fetching | ## Exec Tool @@ -68,9 +104,32 @@ The exec tool is used to execute shell commands. | Config | Type | Default | Description | |------------------------|-------|---------|--------------------------------------------| +| `enabled` | bool | true | Enable the exec tool | | `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | | `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | +### Disabling the Exec Tool + +To completely disable the `exec` tool, set `enabled` to `false`: + +**Via config file:** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**Via environment variable:** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **Note:** When disabled, the agent will not be able to execute shell commands. This also affects the Cron tool's ability to run scheduled shell commands. + ### Functionality - **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns @@ -132,6 +191,7 @@ The cron tool is used for scheduling periodic tasks. | Config | Type | Default | Description | |------------------------|------|---------|------------------------------------------------| | `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | +| `allow_command` | bool | false | Allow cron tasks to execute shell commands | ## MCP Tool @@ -347,9 +407,27 @@ The skills tool configures skill discovery and installation via registries like | `registries.clawhub.enabled` | bool | true | Enable ClawHub registry | | `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL | | `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits | -| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path | -| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path | -| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path | +| `registries.clawhub.search_path` | string | `""` | Search API path | +| `registries.clawhub.skills_path` | string | `""` | Skills API path | +| `registries.clawhub.download_path` | string | `""` | Download API path | +| `registries.clawhub.timeout` | int | 0 | Request timeout in seconds (0 = default) | +| `registries.clawhub.max_zip_size` | int | 0 | Max skill zip size in bytes (0 = default) | +| `registries.clawhub.max_response_size` | int | 0 | Max API response size in bytes (0 = default) | + +### GitHub Integration + +| Config | Type | Default | Description | +|------------------|--------|---------|--------------------------------------| +| `github.proxy` | string | `""` | HTTP proxy for GitHub API requests | +| `github.token` | string | `""` | GitHub personal access token | + +### Search Settings + +| Config | Type | Default | Description | +|---------------------------|------|---------|--------------------------------------------| +| `max_concurrent_searches` | int | 2 | Max concurrent skill search requests | +| `search_cache.max_size` | int | 50 | Max cached search results | +| `search_cache.ttl_seconds`| int | 300 | Cache TTL in seconds | ### Configuration Example @@ -361,11 +439,17 @@ The skills tool configures skill discovery and installation via registries like "clawhub": { "enabled": true, "base_url": "https://clawhub.ai", - "auth_token": "", - "search_path": "/api/v1/search", - "skills_path": "/api/v1/skills", - "download_path": "/api/v1/download" + "auth_token": "" } + }, + "github": { + "proxy": "", + "token": "" + }, + "max_concurrent_searches": 2, + "search_cache": { + "max_size": 50, + "ttl_seconds": 300 } } } @@ -379,6 +463,7 @@ All configuration options can be overridden via environment variables with the f For example: - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 219d2c6e3..096beec78 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -14,7 +14,7 @@ **Fix:** In `~/.picoclaw/config.json` (or your config path): -1. **agents.defaults.model** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`). +1. **agents.defaults.model_name** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`). 2. That entry’s **model** must be a valid OpenRouter model ID, for example: - `"openrouter/free"` – auto free-tier - `"google/gemini-2.0-flash-exp:free"` @@ -26,7 +26,7 @@ Example snippet: { "agents": { "defaults": { - "model": "openrouter-free" + "model_name": "openrouter-free" } }, "model_list": [ diff --git a/docs/vi/ANTIGRAVITY_AUTH.md b/docs/vi/ANTIGRAVITY_AUTH.md new file mode 100644 index 000000000..783dc5181 --- /dev/null +++ b/docs/vi/ANTIGRAVITY_AUTH.md @@ -0,0 +1,807 @@ +> Quay lại [README](../../README.vi.md) + +# Hướng dẫn Xác thực và Tích hợp Antigravity + +## Tổng quan + +**Antigravity** (Google Cloud Code Assist) là nhà cung cấp mô hình AI được Google hỗ trợ, cung cấp quyền truy cập vào các mô hình như Claude Opus 4.6 và Gemini thông qua hạ tầng đám mây của Google. Tài liệu này cung cấp hướng dẫn đầy đủ về cách xác thực hoạt động, cách lấy danh sách mô hình và cách triển khai nhà cung cấp mới trong PicoClaw. + +--- + +## Mục lục + +1. [Luồng xác thực](#luồng-xác-thực) +2. [Chi tiết triển khai OAuth](#chi-tiết-triển-khai-oauth) +3. [Quản lý token](#quản-lý-token) +4. [Lấy danh sách mô hình](#lấy-danh-sách-mô-hình) +5. [Theo dõi mức sử dụng](#theo-dõi-mức-sử-dụng) +6. [Cấu trúc plugin nhà cung cấp](#cấu-trúc-plugin-nhà-cung-cấp) +7. [Yêu cầu tích hợp](#yêu-cầu-tích-hợp) +8. [Các endpoint API](#các-endpoint-api) +9. [Cấu hình](#cấu-hình) +10. [Tạo nhà cung cấp mới trong PicoClaw](#tạo-nhà-cung-cấp-mới-trong-picoclaw) + +--- + +## Luồng xác thực + +### 1. OAuth 2.0 với PKCE + +Antigravity sử dụng **OAuth 2.0 với PKCE (Proof Key for Code Exchange)** để xác thực an toàn: + +``` +┌─────────────┐ ┌─────────────────┐ +│ Client │ ───(1) Generate PKCE Pair────────> │ │ +│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │ +│ │ │ Server │ +│ │ <──(3) Redirect with Code───────── │ │ +│ │ └─────────────────┘ +│ │ ───(4) Exchange Code for Tokens──> │ Token URL │ +│ │ │ │ +│ │ <──(5) Access + Refresh Tokens──── │ │ +└─────────────┘ └─────────────────┘ +``` + +### 2. Các bước chi tiết + +#### Bước 1: Tạo tham số PKCE +```typescript +function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} +``` + +#### Bước 2: Xây dựng URL ủy quyền +```typescript +const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const REDIRECT_URI = "http://localhost:51121/oauth-callback"; + +function buildAuthUrl(params: { challenge: string; state: string }): string { + const url = new URL(AUTH_URL); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", SCOPES.join(" ")); + url.searchParams.set("code_challenge", params.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", params.state); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + return url.toString(); +} +``` + +**Các phạm vi quyền cần thiết:** +```typescript +const SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", +]; +``` + +#### Bước 3: Xử lý callback OAuth + +**Chế độ tự động (Phát triển cục bộ):** +- Khởi động máy chủ HTTP cục bộ trên cổng 51121 +- Chờ chuyển hướng từ Google +- Trích xuất mã ủy quyền từ tham số truy vấn + +**Chế độ thủ công (Từ xa/Không có giao diện):** +- Hiển thị URL ủy quyền cho người dùng +- Người dùng hoàn tất xác thực trong trình duyệt +- Người dùng dán URL chuyển hướng đầy đủ vào terminal +- Phân tích mã từ URL đã dán + +#### Bước 4: Đổi mã lấy token +```typescript +const TOKEN_URL = "https://oauth2.googleapis.com/token"; + +async function exchangeCode(params: { + code: string; + verifier: string; +}): Promise<{ access: string; refresh: string; expires: number }> { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: params.code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + code_verifier: params.verifier, + }), + }); + + const data = await response.json(); + + return { + access: data.access_token, + refresh: data.refresh_token, + expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer + }; +} +``` + +#### Bước 5: Lấy dữ liệu người dùng bổ sung + +**Email người dùng:** +```typescript +async function fetchUserEmail(accessToken: string): Promise { + const response = await fetch( + "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + const data = await response.json(); + return data.email; +} +``` + +**ID dự án (Bắt buộc cho các lệnh gọi API):** +```typescript +async function fetchProjectId(accessToken: string): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": JSON.stringify({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }), + }; + + const response = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers, + body: JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + const data = await response.json(); + return data.cloudaicompanionProject || "rising-fact-p41fc"; // Giá trị mặc định dự phòng +} +``` + +--- + +## Chi tiết triển khai OAuth + +### Thông tin xác thực client + +**Quan trọng:** Các giá trị này được mã hóa base64 trong mã nguồn để đồng bộ với pi-ai: + +```typescript +const decode = (s: string) => Buffer.from(s, "base64").toString(); + +const CLIENT_ID = decode( + "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==" +); +const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); +``` + +### Các chế độ luồng OAuth + +1. **Luồng tự động** (Máy cục bộ có trình duyệt): + - Tự động mở trình duyệt + - Máy chủ callback cục bộ bắt chuyển hướng + - Không cần tương tác người dùng sau xác thực ban đầu + +2. **Luồng thủ công** (Từ xa/Không có giao diện/WSL2): + - Hiển thị URL để sao chép-dán thủ công + - Người dùng hoàn tất xác thực trong trình duyệt bên ngoài + - Người dùng dán lại URL chuyển hướng đầy đủ + +```typescript +function shouldUseManualOAuthFlow(isRemote: boolean): boolean { + return isRemote || isWSL2Sync(); +} +``` + +--- + +## Quản lý token + +### Cấu trúc hồ sơ xác thực + +```typescript +type OAuthCredential = { + type: "oauth"; + provider: "google-antigravity"; + access: string; // Token truy cập + refresh: string; // Token làm mới + expires: number; // Dấu thời gian hết hạn (ms kể từ epoch) + email?: string; // Email người dùng + projectId?: string; // ID dự án Google Cloud +}; +``` + +### Làm mới token + +Thông tin xác thực bao gồm token làm mới có thể được sử dụng để lấy token truy cập mới khi token hiện tại hết hạn. Thời gian hết hạn được đặt với bộ đệm 5 phút để tránh điều kiện tranh chấp. + +--- + +## Lấy danh sách mô hình + +### Lấy các mô hình khả dụng + +```typescript +const BASE_URL = "https://cloudcode-pa.googleapis.com"; + +async function fetchAvailableModels( + accessToken: string, + projectId: string +): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + }; + + const response = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers, + body: JSON.stringify({ project: projectId }), + } + ); + + const data = await response.json(); + + // Trả về các mô hình kèm thông tin hạn mức + return Object.entries(data.models).map(([modelId, modelInfo]) => ({ + id: modelId, + displayName: modelInfo.displayName, + quotaInfo: { + remainingFraction: modelInfo.quotaInfo?.remainingFraction, + resetTime: modelInfo.quotaInfo?.resetTime, + isExhausted: modelInfo.quotaInfo?.isExhausted, + }, + })); +} +``` + +### Định dạng phản hồi + +```typescript +type FetchAvailableModelsResponse = { + models?: Record; +}; +``` + +--- + +## Theo dõi mức sử dụng + +### Lấy dữ liệu sử dụng + +```typescript +export async function fetchAntigravityUsage( + token: string, + timeoutMs: number +): Promise { + // 1. Lấy thông tin tín dụng và gói dịch vụ + const loadCodeAssistRes = await fetch( + `${BASE_URL}/v1internal:loadCodeAssist`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: { + ideType: "ANTIGRAVITY", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + // Trích xuất thông tin tín dụng + const { availablePromptCredits, planInfo, currentTier } = data; + + // 2. Lấy hạn mức mô hình + const modelsRes = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ project: projectId }), + } + ); + + // Xây dựng cửa sổ sử dụng + return { + provider: "google-antigravity", + displayName: "Google Antigravity", + windows: [ + { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) }, + // Hạn mức từng mô hình... + ], + plan: currentTier?.name || planType, + }; +} +``` + +### Cấu trúc phản hồi sử dụng + +```typescript +type ProviderUsageSnapshot = { + provider: "google-antigravity"; + displayName: string; + windows: UsageWindow[]; + plan?: string; + error?: string; +}; + +type UsageWindow = { + label: string; // "Credits" hoặc ID mô hình + usedPercent: number; // 0-100 + resetAt?: number; // Dấu thời gian khi hạn mức được đặt lại +}; +``` + +--- + +## Cấu trúc plugin nhà cung cấp + +### Định nghĩa plugin + +```typescript +const antigravityPlugin = { + id: "google-antigravity-auth", + name: "Google Antigravity Auth", + description: "OAuth flow for Google Antigravity (Cloud Code Assist)", + configSchema: emptyPluginConfigSchema(), + + register(api: PicoClawPluginApi) { + api.registerProvider({ + id: "google-antigravity", + label: "Google Antigravity", + docsPath: "/providers/models", + aliases: ["antigravity"], + + auth: [ + { + id: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + kind: "oauth", + run: async (ctx: ProviderAuthContext) => { + // Triển khai OAuth tại đây + }, + }, + ], + }); + }, +}; +``` + +### ProviderAuthContext + +```typescript +type ProviderAuthContext = { + config: PicoClawConfig; + agentDir?: string; + workspaceDir?: string; + prompter: WizardPrompter; // Lời nhắc/thông báo UI + runtime: RuntimeEnv; // Ghi log, v.v. + isRemote: boolean; // Có đang chạy từ xa không + openUrl: (url: string) => Promise; // Mở trình duyệt + oauth: { + createVpsAwareHandlers: Function; + }; +}; +``` + +### ProviderAuthResult + +```typescript +type ProviderAuthResult = { + profiles: Array<{ + profileId: string; + credential: AuthProfileCredential; + }>; + configPatch?: Partial; + defaultModel?: string; + notes?: string[]; +}; +``` + +--- + +## Yêu cầu tích hợp + +### 1. Môi trường/Phụ thuộc cần thiết + +- Go ≥ 1.25 +- Mã nguồn PicoClaw (`pkg/providers/` và `pkg/auth/`) +- Các gói thư viện chuẩn `crypto` và `net/http` + +### 2. Các header bắt buộc cho lệnh gọi API + +```typescript +const REQUIRED_HEADERS = { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", // hoặc "google-api-nodejs-client/9.15.1" + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", +}; + +// Đối với các lệnh gọi loadCodeAssist, cũng bao gồm: +const CLIENT_METADATA = { + ideType: "ANTIGRAVITY", // hoặc "IDE_UNSPECIFIED" + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", +}; +``` + +### 3. Làm sạch schema mô hình + +Antigravity sử dụng các mô hình tương thích Gemini, vì vậy schema công cụ phải được làm sạch: + +```typescript +const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([ + "patternProperties", + "additionalProperties", + "$schema", + "$id", + "$ref", + "$defs", + "definitions", + "examples", + "minLength", + "maxLength", + "minimum", + "maximum", + "multipleOf", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems", + "minProperties", + "maxProperties", +]); + +// Làm sạch schema trước khi gửi +function cleanToolSchemaForGemini(schema: Record): unknown { + // Xóa các từ khóa không được hỗ trợ + // Đảm bảo cấp cao nhất có type: "object" + // Làm phẳng các union anyOf/oneOf +} +``` + +### 4. Xử lý khối suy nghĩ (Mô hình Claude) + +Đối với các mô hình Claude qua Antigravity, khối suy nghĩ cần xử lý đặc biệt: + +```typescript +const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +export function sanitizeAntigravityThinkingBlocks( + messages: AgentMessage[] +): AgentMessage[] { + // Xác thực chữ ký suy nghĩ + // Chuẩn hóa các trường chữ ký + // Loại bỏ các khối suy nghĩ chưa ký +} +``` + +--- + +## Các endpoint API + +### Endpoint xác thực + +| Endpoint | Phương thức | Mục đích | +|----------|------------|----------| +| `https://accounts.google.com/o/oauth2/v2/auth` | GET | Ủy quyền OAuth | +| `https://oauth2.googleapis.com/token` | POST | Trao đổi token | +| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | Thông tin người dùng (email) | + +### Endpoint Cloud Code Assist + +| Endpoint | Phương thức | Mục đích | +|----------|------------|----------| +| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | Tải thông tin dự án, tín dụng, gói dịch vụ | +| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | Liệt kê các mô hình khả dụng kèm hạn mức | +| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | Endpoint streaming chat | + +**Định dạng yêu cầu API (Chat):** +Endpoint `v1internal:streamGenerateContent` yêu cầu một envelope bao bọc yêu cầu Gemini tiêu chuẩn: + +```json +{ + "project": "your-project-id", + "model": "model-id", + "request": { + "contents": [...], + "systemInstruction": {...}, + "generationConfig": {...}, + "tools": [...] + }, + "requestType": "agent", + "userAgent": "antigravity", + "requestId": "agent-timestamp-random" +} +``` + +**Định dạng phản hồi API (SSE):** +Mỗi thông điệp SSE (`data: {...}`) được bao bọc trong trường `response`: + +```json +{ + "response": { + "candidates": [...], + "usageMetadata": {...}, + "modelVersion": "...", + "responseId": "..." + }, + "traceId": "...", + "metadata": {} +} +``` + +--- + +## Cấu hình + +### Cấu hình config.json + +```json +{ + "model_list": [ + { + "model_name": "gemini-flash", + "model": "antigravity/gemini-3-flash", + "auth_method": "oauth" + } + ], + "agents": { + "defaults": { + "model_name": "gemini-flash" + } + } +} +``` + +### Lưu trữ hồ sơ xác thực + +Hồ sơ xác thực được lưu trữ trong `~/.picoclaw/auth.json`: + +```json +{ + "credentials": { + "google-antigravity": { + "access_token": "ya29...", + "refresh_token": "1//...", + "expires_at": "2026-01-01T00:00:00Z", + "provider": "google-antigravity", + "auth_method": "oauth", + "email": "user@example.com", + "project_id": "my-project-id" + } + } +} +``` + +--- + +## Tạo nhà cung cấp mới trong PicoClaw + +Các nhà cung cấp PicoClaw được triển khai dưới dạng gói Go trong `pkg/providers/`. Để thêm nhà cung cấp mới: + +### Triển khai từng bước + +#### 1. Tạo file nhà cung cấp + +Tạo file Go mới trong `pkg/providers/`: + +``` +pkg/providers/ +└── your_provider.go +``` + +#### 2. Triển khai interface Provider + +Nhà cung cấp của bạn phải triển khai interface `Provider` được định nghĩa trong `pkg/providers/types.go`: + +```go +package providers + +type YourProvider struct { + apiKey string + apiBase string +} + +func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider { + if apiBase == "" { + apiBase = "https://api.your-provider.com/v1" + } + return &YourProvider{apiKey: apiKey, apiBase: apiBase} +} + +func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error { + // Triển khai hoàn thành chat với streaming +} +``` + +#### 3. Đăng ký trong factory + +Thêm nhà cung cấp của bạn vào switch giao thức trong `pkg/providers/factory.go`: + +```go +case "your-provider": + return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil +``` + +#### 4. Thêm cấu hình mặc định (Tùy chọn) + +Thêm mục mặc định trong `pkg/config/defaults.go`: + +```go +{ + ModelName: "your-model", + Model: "your-provider/model-name", + APIKey: "", +}, +``` + +#### 5. Thêm hỗ trợ xác thực (Tùy chọn) + +Nếu nhà cung cấp của bạn yêu cầu OAuth hoặc xác thực đặc biệt, thêm case vào `cmd/picoclaw/internal/auth/helpers.go`: + +```go +case "your-provider": + authLoginYourProvider() +``` + +#### 6. Cấu hình qua `config.json` + +```json +{ + "model_list": [ + { + "model_name": "your-model", + "model": "your-provider/model-name", + "api_key": "your-api-key", + "api_base": "https://api.your-provider.com/v1" + } + ] +} +``` + +--- + +## Kiểm thử triển khai của bạn + +### Lệnh CLI + +```bash +# Xác thực với nhà cung cấp +picoclaw auth login --provider your-provider + +# Liệt kê mô hình (cho Antigravity) +picoclaw auth models + +# Khởi động gateway +picoclaw gateway + +# Chạy agent với mô hình cụ thể +picoclaw agent -m "Hello" --model your-model +``` + +### Biến môi trường cho kiểm thử + +```bash +# Ghi đè mô hình mặc định +export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model + +# Ghi đè cài đặt nhà cung cấp +export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]' +``` + +--- + +## Tài liệu tham khảo + +- **File nguồn:** + - `pkg/providers/antigravity_provider.go` - Triển khai nhà cung cấp Antigravity + - `pkg/auth/oauth.go` - Triển khai luồng OAuth + - `pkg/auth/store.go` - Lưu trữ thông tin xác thực (`~/.picoclaw/auth.json`) + - `pkg/providers/factory.go` - Factory nhà cung cấp và định tuyến giao thức + - `pkg/providers/types.go` - Định nghĩa interface nhà cung cấp + - `cmd/picoclaw/internal/auth/helpers.go` - Lệnh CLI xác thực + +- **Tài liệu:** + - `docs/ANTIGRAVITY_USAGE.md` - Hướng dẫn sử dụng Antigravity + - `docs/migration/model-list-migration.md` - Hướng dẫn di chuyển + +--- + +## Lưu ý + +1. **Dự án Google Cloud:** Antigravity yêu cầu Gemini for Google Cloud được bật trên dự án Google Cloud của bạn +2. **Hạn mức:** Sử dụng hạn mức dự án Google Cloud (không tính phí riêng) +3. **Truy cập mô hình:** Các mô hình khả dụng phụ thuộc vào cấu hình dự án Google Cloud của bạn +4. **Khối suy nghĩ:** Mô hình Claude qua Antigravity yêu cầu xử lý đặc biệt khối suy nghĩ có chữ ký +5. **Làm sạch schema:** Schema công cụ phải được làm sạch để loại bỏ các từ khóa JSON Schema không được hỗ trợ + +--- + +## Xử lý lỗi thường gặp + +### 1. Giới hạn tốc độ (HTTP 429) + +Antigravity trả về lỗi 429 khi hạn mức dự án/mô hình đã cạn kiệt. Phản hồi lỗi thường chứa `quotaResetDelay` trong trường `details`. + +**Ví dụ lỗi 429:** +```json +{ + "error": { + "code": 429, + "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.", + "status": "RESOURCE_EXHAUSTED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "metadata": { + "quotaResetDelay": "4h30m28.060903746s" + } + } + ] + } +} +``` + +### 2. Phản hồi trống (Mô hình bị hạn chế) + +Một số mô hình có thể xuất hiện trong danh sách mô hình khả dụng nhưng trả về phản hồi trống (200 OK nhưng luồng SSE trống). Điều này thường xảy ra với các mô hình xem trước hoặc bị hạn chế mà dự án hiện tại không có quyền sử dụng. + +**Cách xử lý:** Coi phản hồi trống là lỗi, thông báo cho người dùng rằng mô hình có thể bị hạn chế hoặc không hợp lệ cho dự án của họ. + +--- + +## Khắc phục sự cố + +### "Token expired" (Token đã hết hạn) +- Làm mới token OAuth: `picoclaw auth login --provider antigravity` + +### "Gemini for Google Cloud is not enabled" (Gemini for Google Cloud chưa được bật) +- Bật API trong Google Cloud Console của bạn + +### "Project not found" (Không tìm thấy dự án) +- Đảm bảo dự án Google Cloud của bạn đã bật các API cần thiết +- Kiểm tra xem ID dự án có được lấy chính xác trong quá trình xác thực không + +### Mô hình không xuất hiện trong danh sách +- Xác minh xác thực OAuth đã hoàn tất thành công +- Kiểm tra lưu trữ hồ sơ xác thực: `~/.picoclaw/auth.json` +- Chạy lại `picoclaw auth login --provider antigravity` diff --git a/docs/vi/ANTIGRAVITY_USAGE.md b/docs/vi/ANTIGRAVITY_USAGE.md new file mode 100644 index 000000000..4a696f770 --- /dev/null +++ b/docs/vi/ANTIGRAVITY_USAGE.md @@ -0,0 +1,72 @@ +> Quay lại [README](../../README.vi.md) + +# Sử dụng nhà cung cấp Antigravity trong PicoClaw + +Hướng dẫn này giải thích cách thiết lập và sử dụng nhà cung cấp **Antigravity** (Google Cloud Code Assist) trong PicoClaw. + +## Điều kiện tiên quyết + +1. Một tài khoản Google. +2. Đã kích hoạt Google Cloud Code Assist (thường có sẵn thông qua quy trình giới thiệu "Gemini for Google Cloud"). + +## 1. Xác thực + +Để xác thực với Antigravity, chạy lệnh sau: + +```bash +picoclaw auth login --provider antigravity +``` + +### Xác thực thủ công (Headless/VPS) +Nếu bạn đang chạy trên máy chủ (Coolify/Docker) và không thể truy cập `localhost`, hãy làm theo các bước sau: +1. Chạy lệnh ở trên. +2. Sao chép URL được cung cấp và mở nó trong trình duyệt cục bộ của bạn. +3. Hoàn tất đăng nhập. +4. Trình duyệt của bạn sẽ chuyển hướng đến URL `localhost:51121` (trang sẽ không tải được). +5. **Sao chép URL cuối cùng đó** từ thanh địa chỉ trình duyệt. +6. **Dán nó vào terminal** nơi PicoClaw đang chờ. + +PicoClaw sẽ tự động trích xuất mã ủy quyền và hoàn tất quy trình. + +## 2. Quản lý mô hình + +### Liệt kê các mô hình khả dụng +Để xem dự án của bạn có quyền truy cập vào những mô hình nào và kiểm tra hạn mức của chúng: + +```bash +picoclaw auth models +``` + +### Chuyển đổi mô hình +Bạn có thể thay đổi mô hình mặc định trong `~/.picoclaw/config.json` hoặc ghi đè qua CLI: + +```bash +# Ghi đè cho một lệnh duy nhất +picoclaw agent -m "Hello" --model claude-opus-4-6-thinking +``` + +## 3. Sử dụng thực tế (Coolify/Docker) + +Nếu bạn đang triển khai qua Coolify hoặc Docker, hãy làm theo các bước sau để kiểm tra: + +1. **Biến môi trường**: + * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash` +2. **Lưu trữ xác thực**: + Nếu bạn đã đăng nhập cục bộ, bạn có thể sao chép thông tin xác thực lên máy chủ: + ```bash + scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/ + ``` + *Hoặc*, chạy lệnh `auth login` một lần trên máy chủ nếu bạn có quyền truy cập terminal. + +## 4. Khắc phục sự cố + +* **Phản hồi trống**: Nếu một mô hình trả về phản hồi trống, nó có thể bị hạn chế cho dự án của bạn. Hãy thử `gemini-3-flash` hoặc `claude-opus-4-6-thinking`. +* **429 Giới hạn tốc độ**: Antigravity có hạn mức nghiêm ngặt. PicoClaw sẽ hiển thị "thời gian đặt lại" trong thông báo lỗi nếu bạn đạt đến giới hạn. +* **404 Không tìm thấy**: Đảm bảo bạn đang sử dụng ID mô hình từ danh sách `picoclaw auth models`. Sử dụng ID ngắn (ví dụ: `gemini-3-flash`) thay vì đường dẫn đầy đủ. + +## 5. Tóm tắt các mô hình hoạt động tốt + +Dựa trên kiểm tra, các mô hình sau đáng tin cậy nhất: +* `gemini-3-flash` (Nhanh, khả dụng cao) +* `gemini-2.5-flash-lite` (Nhẹ) +* `claude-opus-4-6-thinking` (Mạnh mẽ, bao gồm khả năng suy luận) diff --git a/docs/vi/chat-apps.md b/docs/vi/chat-apps.md index 1fefa00d3..3680fed69 100644 --- a/docs/vi/chat-apps.md +++ b/docs/vi/chat-apps.md @@ -8,22 +8,22 @@ Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix > **Lưu ý**: Tất cả các kênh dựa trên webhook (LINE, WeCom, v.v.) được phục vụ trên một máy chủ HTTP Gateway chung (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Không có port riêng cho từng kênh. Lưu ý: Feishu sử dụng chế độ WebSocket/SDK và không sử dụng máy chủ HTTP webhook chung. -| Channel | Setup | -| ------------ | ---------------------------------- | -| **Telegram** | Easy (just a token) | -| **Discord** | Easy (bot token + intents) | -| **WhatsApp** | Easy (native: QR scan; or bridge URL) | -| **Matrix** | Medium (homeserver + bot access token) | -| **QQ** | Easy (AppID + AppSecret) | -| **DingTalk** | Medium (app credentials) | -| **LINE** | Medium (credentials + webhook URL) | -| **WeCom AI Bot** | Medium (Token + AES key) | -| **Feishu** | Medium (App ID + Secret, WebSocket mode) | -| **Slack** | Medium (Bot token + App token) | -| **IRC** | Medium (server + TLS config) | -| **OneBot** | Medium (QQ via OneBot protocol) | -| **MaixCam** | Easy (Sipeed hardware integration) | -| **Pico** | Native PicoClaw protocol | +| Kênh | Độ khó | Mô tả | Tài liệu | +| -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| **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) | +| **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) | +| **DingTalk** | ⭐⭐ Trung bình | Chế độ Stream (không cần IP công khai), doanh nghiệp | [Tài liệu](../channels/dingtalk/README.vi.md) | +| **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 | - | +| **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ị) @@ -168,12 +168,13 @@ Nếu `session_store_path` trống, phiên được lưu tại `/what
QQ -**1. Tạo bot** +**Thiết lập nhanh (khuyến nghị)** -- Truy cập [QQ Open Platform](https://q.qq.com/#) -- Tạo ứng dụng → Lấy **AppID** và **AppSecret** +QQ Open Platform cung cấp trang thiết lập một chạm cho bot tương thích OpenClaw: -**2. Cấu hình** +1. Mở [QQ Bot Quick Start](https://q.qq.com/qqbot/openclaw/index.html) và quét mã QR để đăng nhập +2. Bot được tạo tự động — sao chép **App ID** và **App Secret** +3. Cấu hình PicoClaw: ```json { @@ -188,13 +189,20 @@ Nếu `session_store_path` trống, phiên được lưu tại `/what } ``` -> Đặt `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định số QQ để giới hạn truy cập. +4. Chạy `picoclaw gateway` và mở QQ để trò chuyện với bot của bạn -**3. Chạy** +> App Secret chỉ hiển thị một lần. Lưu ngay lập tức — xem lại sẽ buộc phải đặt lại. +> +> Bot được tạo qua trang thiết lập nhanh ban đầu chỉ dành cho người tạo và không hỗ trợ chat nhóm. Để bật quyền truy cập nhóm, cấu hình chế độ sandbox trên [QQ Open Platform](https://q.qq.com/). -```bash -picoclaw gateway -``` +**Thiết lập thủ công** + +Nếu bạn muốn tạo bot thủ công: + +* Đăng nhập tại [QQ Open Platform](https://q.qq.com/) để đăng ký làm nhà phát triển +* Tạo bot QQ — tùy chỉnh avatar và tên +* Sao chép **App ID** và **App Secret** từ cài đặt bot +* Cấu hình như trên và chạy `picoclaw gateway`
@@ -229,8 +237,31 @@ picoclaw gateway ```bash picoclaw gateway ``` +
+
+MaixCam + +Kênh tích hợp được thiết kế đặc biệt cho phần cứng camera AI Sipeed. + +```json +{ + "channels": { + "maixcam": { + "enabled": true + } + } +} +``` + +```bash +picoclaw gateway +``` + +
+ +
Matrix @@ -261,7 +292,7 @@ picoclaw gateway picoclaw gateway ``` -Để xem đầy đủ các tùy chọn (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), xem [Hướng Dẫn Cấu Hình Kênh Matrix](docs/channels/matrix/README.md). +Để xem đầy đủ các tùy chọn (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), xem [Hướng Dẫn Cấu Hình Kênh Matrix](../channels/matrix/README.md).
@@ -322,7 +353,7 @@ PicoClaw hỗ trợ ba loại tích hợp WeCom: **Tùy chọn 2: WeCom App (App Tùy chỉnh)** - Nhiều tính năng hơn, nhắn tin chủ động, chỉ chat riêng **Tùy chọn 3: WeCom AI Bot (AI Bot)** - AI Bot chính thức, phản hồi streaming, hỗ trợ chat nhóm & riêng -Xem [Hướng Dẫn Cấu Hình WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) để biết hướng dẫn thiết lập chi tiết. +Xem [Hướng Dẫn Cấu Hình WeCom AI Bot](../channels/wecom/wecom_aibot/README.vi.md) để biết hướng dẫn thiết lập chi tiết. **Thiết Lập Nhanh - WeCom Bot:** @@ -396,7 +427,7 @@ picoclaw gateway **1. Tạo AI Bot** * Truy cập Console Quản Trị WeCom → Quản Lý App → AI Bot -* Trong cài đặt AI Bot, cấu hình callback URL: `http://your-server:18791/webhook/wecom-aibot` +* Trong cài đặt AI Bot, cấu hình callback URL: `http://your-server:18790/webhook/wecom-aibot` * Sao chép **Token** và nhấp "Tạo Ngẫu Nhiên" cho **EncodingAESKey** **2. Cấu hình** @@ -410,7 +441,8 @@ picoclaw gateway "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], - "welcome_message": "Hello! How can I help you?" + "welcome_message": "Hello! How can I help you?", + "processing_message": "⏳ Processing, please wait. The results will be sent shortly." } } } @@ -425,3 +457,169 @@ picoclaw gateway > **Lưu ý**: WeCom AI Bot sử dụng giao thức streaming pull — không lo timeout phản hồi. Tác vụ dài (>30 giây) tự động chuyển sang gửi qua `response_url` push.
+ +
+Feishu (Lark) + +PicoClaw kết nối với Feishu qua chế độ WebSocket/SDK — không cần URL webhook công khai hay máy chủ callback. + +**1. Tạo ứng dụng** + +* Truy cập [Feishu Open Platform](https://open.feishu.cn/) và tạo ứng dụng +* Trong cài đặt ứng dụng, bật khả năng **Bot** +* Tạo phiên bản và xuất bản ứng dụng (ứng dụng phải được xuất bản mới có hiệu lực) +* Sao chép **App ID** (bắt đầu bằng `cli_`) và **App Secret** + +**2. Cấu hình** + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "app_id": "cli_xxx", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +Tùy chọn: `encrypt_key` và `verification_token` để mã hóa sự kiện (khuyến nghị cho môi trường production). + +**3. Chạy và trò chuyện** + +```bash +picoclaw gateway +``` + +Mở Feishu, tìm tên bot của bạn và bắt đầu trò chuyện. Bạn cũng có thể thêm bot vào nhóm — sử dụng `group_trigger.mention_only: true` để chỉ phản hồi khi được @mention. + +Để xem đầy đủ các tùy chọn, xem [Hướng Dẫn Cấu Hình Kênh Feishu](../channels/feishu/README.vi.md). + +
+ +
+Slack + +**1. Tạo ứng dụng Slack** + +* Truy cập [Slack API](https://api.slack.com/apps) và tạo ứng dụng mới +* Trong **OAuth & Permissions**, thêm các scope bot: `chat:write`, `app_mentions:read`, `im:history`, `im:read`, `im:write` +* Cài đặt ứng dụng vào workspace của bạn +* Sao chép **Bot Token** (`xoxb-...`) và **App-Level Token** (`xapp-...`, bật Socket Mode để lấy token này) + +**2. Cấu hình** + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "xoxb-YOUR-BOT-TOKEN", + "app_token": "xapp-YOUR-APP-TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Chạy** + +```bash +picoclaw gateway +``` + +
+ +
+IRC + +**1. Cấu hình** + +```json +{ + "channels": { + "irc": { + "enabled": true, + "server": "irc.libera.chat:6697", + "tls": true, + "nick": "picoclaw-bot", + "channels": ["#your-channel"], + "password": "", + "allow_from": [] + } + } +} +``` + +Tùy chọn: `nickserv_password` để xác thực NickServ, `sasl_user`/`sasl_password` để xác thực SASL. + +**2. Chạy** + +```bash +picoclaw gateway +``` + +Bot sẽ kết nối đến máy chủ IRC và tham gia các kênh đã chỉ định. + +
+ +
+OneBot (QQ qua giao thức OneBot) + +OneBot là giao thức mở cho bot QQ. PicoClaw kết nối với bất kỳ triển khai tương thích OneBot v11 nào (ví dụ: [Lagrange](https://github.com/LagrangeDev/Lagrange.Core), [NapCat](https://github.com/NapNeko/NapCatQQ)) qua WebSocket. + +**1. Thiết lập triển khai OneBot** + +Cài đặt và chạy framework bot QQ tương thích OneBot v11. Bật máy chủ WebSocket của nó. + +**2. Cấu hình** + +```json +{ + "channels": { + "onebot": { + "enabled": true, + "ws_url": "ws://127.0.0.1:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| Trường | Mô tả | +|--------|-------| +| `ws_url` | URL WebSocket của triển khai OneBot | +| `access_token` | Token truy cập để xác thực (nếu đã cấu hình trong OneBot) | +| `reconnect_interval` | Khoảng thời gian kết nối lại tính bằng giây (mặc định: 5) | + +**3. Chạy** + +```bash +picoclaw gateway +``` + +
+ +
+MaixCam + +Kênh tích hợp được thiết kế đặc biệt cho phần cứng camera AI Sipeed. + +```json +{ + "channels": { + "maixcam": { + "enabled": true + } + } +} +``` + +```bash +picoclaw gateway +``` + +
diff --git a/docs/vi/configuration.md b/docs/vi/configuration.md index 847f28e60..a21929359 100644 --- a/docs/vi/configuration.md +++ b/docs/vi/configuration.md @@ -57,7 +57,7 @@ Mặc định, skill được tải từ: 1. `~/.picoclaw/workspace/skills` (workspace) 2. `~/.picoclaw/skills` (global) -3. `/skills` (builtin) +3. `<đường-dẫn-nhúng-khi-build>/skills` (tích hợp) Cho thiết lập nâng cao/test, bạn có thể ghi đè thư mục gốc skill builtin với: diff --git a/docs/vi/credential_encryption.md b/docs/vi/credential_encryption.md new file mode 100644 index 000000000..9ba24588b --- /dev/null +++ b/docs/vi/credential_encryption.md @@ -0,0 +1,159 @@ +> Quay lại [README](../../README.vi.md) + +# Mã hóa Thông tin Xác thực + +PicoClaw hỗ trợ mã hóa các giá trị `api_key` trong các mục cấu hình `model_list`. +Các khóa đã mã hóa được lưu trữ dưới dạng chuỗi `enc://` và được giải mã tự động khi khởi động. + +--- + +## Bắt đầu Nhanh + +**1. Đặt cụm mật khẩu** + +```bash +export PICOCLAW_KEY_PASSPHRASE="your-passphrase" +``` + +**2. Mã hóa khóa API** + +Chạy `picoclaw onboard` — nó yêu cầu nhập cụm mật khẩu và tạo khóa SSH, +sau đó tự động mã hóa lại tất cả các mục `api_key` dạng văn bản thuần trong cấu hình +ở lần gọi `SaveConfig` tiếp theo. Giá trị `enc://` kết quả sẽ có dạng: + +``` +enc://AAAA...base64... +``` + +**3. Dán kết quả vào cấu hình** + +```json +{ + "model_list": [ + { + "model_name": "gpt-4o", + "model": "openai/gpt-4o", + "api_key": "enc://AAAA...base64...", + "api_base": "https://api.openai.com/v1" + } + ] +} +``` + +--- + +## Các Định dạng `api_key` được Hỗ trợ + +| Định dạng | Ví dụ | Hành vi | +|-----------|-------|---------| +| Văn bản thuần | `sk-abc123` | Sử dụng nguyên trạng | +| Tham chiếu tệp | `file://openai.key` | Nội dung được đọc từ cùng thư mục với tệp cấu hình | +| Đã mã hóa | `enc://` | Giải mã khi khởi động bằng `PICOCLAW_KEY_PASSPHRASE` | +| Trống | `""` | Truyền qua không thay đổi (dùng với `auth_method: oauth`) | + +--- + +## Thiết kế Mật mã + +### Dẫn xuất Khóa + +Mã hóa sử dụng **HKDF-SHA256** với khóa riêng SSH làm yếu tố thứ hai. + +``` +sshHash = SHA256(ssh_private_key_file_bytes) +ikm = HMAC-SHA256(key=sshHash, message=passphrase) +aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +``` + +### Mã hóa + +``` +AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key) +``` + +### Định dạng Truyền tải + +``` +enc:// +``` + +| Trường | Kích thước | Mô tả | +|--------|-----------|-------| +| `salt` | 16 byte | Ngẫu nhiên mỗi lần mã hóa; đưa vào HKDF | +| `nonce` | 12 byte | Ngẫu nhiên mỗi lần mã hóa; IV của AES-GCM | +| `ciphertext` | thay đổi | Bản mã AES-256-GCM + thẻ xác thực 16 byte | + +Thẻ xác thực GCM được tự động nối vào bản mã. Bất kỳ sự giả mạo nào đều khiến giải mã thất bại với lỗi thay vì trả về văn bản thuần bị hỏng. + +### Hiệu suất + +| Thao tác | Thời gian (ARM Cortex-A) | +|----------|--------------------------| +| Dẫn xuất khóa (HKDF) | < 1 ms | +| Giải mã AES-256-GCM | < 1 ms | +| **Tổng chi phí khởi động** | **< 2 ms mỗi khóa** | + +--- + +## Bảo mật Hai Yếu tố với Khóa SSH + +Khi khóa riêng SSH được cung cấp, việc phá vỡ mã hóa yêu cầu **cả hai**: + +1. **Cụm mật khẩu** (`PICOCLAW_KEY_PASSPHRASE`) +2. **Tệp khóa riêng SSH** + +Điều này có nghĩa là chỉ rò rỉ tệp cấu hình không đủ để khôi phục khóa API, ngay cả khi cụm mật khẩu yếu. Khóa SSH đóng góp 256 bit entropy (Ed25519) bất kể độ mạnh của cụm mật khẩu. + +### Mô hình Mối đe dọa + +| Kẻ tấn công có | Có thể giải mã? | +|----------------|-----------------| +| Chỉ tệp cấu hình | Không — cần cụm mật khẩu + khóa SSH | +| Chỉ khóa SSH | Không — cần cụm mật khẩu | +| Chỉ cụm mật khẩu | Không — cần khóa SSH | +| Tệp cấu hình + khóa SSH + cụm mật khẩu | Có — xâm phạm hoàn toàn | + +--- + +## Biến Môi trường + +| Biến | Bắt buộc | Mô tả | +|------|----------|-------| +| `PICOCLAW_KEY_PASSPHRASE` | Có (cho `enc://`) | Cụm mật khẩu dùng để dẫn xuất khóa | +| `PICOCLAW_SSH_KEY_PATH` | Không | Đường dẫn đến khóa riêng SSH. Nếu không đặt, tự động phát hiện từ `~/.ssh/picoclaw_ed25519.key` | + +### Tự động Phát hiện Khóa SSH + +Nếu `PICOCLAW_SSH_KEY_PATH` không được đặt, PicoClaw tìm khóa chuyên dụng: + +``` +~/.ssh/picoclaw_ed25519.key +``` + +Tệp chuyên dụng này tránh xung đột với các khóa SSH hiện có của người dùng. +Chạy `picoclaw onboard` để tạo tự động. + +`os.UserHomeDir()` được sử dụng để phân giải thư mục home đa nền tảng (đọc `USERPROFILE` trên Windows, `HOME` trên Unix/macOS). + +> **Lưu ý:** Tệp khóa SSH là bắt buộc cho mã hóa thông tin xác thực. Nếu không tìm thấy khóa và `PICOCLAW_SSH_KEY_PATH` không được đặt, mã hóa/giải mã sẽ thất bại. Chạy `picoclaw onboard` để tạo khóa tự động. + +--- + +## Di chuyển + +Vì tài liệu bí mật duy nhất là `PICOCLAW_KEY_PASSPHRASE` và tệp khóa riêng SSH, việc di chuyển rất đơn giản: + +1. Sao chép tệp cấu hình sang máy mới. +2. Đặt `PICOCLAW_KEY_PASSPHRASE` với cùng giá trị. +3. Sao chép tệp khóa riêng SSH đến cùng đường dẫn (hoặc đặt `PICOCLAW_SSH_KEY_PATH` đến vị trí mới). + +Không cần mã hóa lại. + +--- + +## Lưu ý về Bảo mật + +- **Cả cụm mật khẩu và khóa SSH đều bắt buộc.** Khóa SSH đóng vai trò yếu tố thứ hai — không có nó, mã hóa/giải mã sẽ thất bại. Chạy `picoclaw onboard` để tạo khóa nếu chưa tồn tại. +- **Khóa SSH chỉ đọc khi chạy.** PicoClaw không bao giờ ghi hoặc sửa đổi tệp khóa SSH. +- **Khóa văn bản thuần vẫn được hỗ trợ.** Các cấu hình hiện có không dùng `enc://` không bị ảnh hưởng. +- **Định dạng `enc://` được quản lý phiên bản** thông qua trường `info` của HKDF (`picoclaw-credential-v1`), cho phép nâng cấp thuật toán trong tương lai mà không làm hỏng các giá trị đã mã hóa hiện có. diff --git a/docs/vi/debug.md b/docs/vi/debug.md new file mode 100644 index 000000000..69583d486 --- /dev/null +++ b/docs/vi/debug.md @@ -0,0 +1,36 @@ +# Gỡ lỗi PicoClaw + +> Quay lại [README](../../README.vi.md) + +PicoClaw thực hiện nhiều tương tác phức tạp ở hậu trường cho mỗi yêu cầu nhận được — từ định tuyến tin nhắn và đánh giá độ phức tạp, đến thực thi công cụ và thích ứng với lỗi mô hình. Khả năng xem chính xác những gì đang xảy ra là rất quan trọng, không chỉ để khắc phục các sự cố tiềm ẩn, mà còn để thực sự hiểu cách agent hoạt động. + +## Khởi động PicoClaw ở chế độ gỡ lỗi + +Để nhận thông tin chi tiết về những gì agent đang thực hiện (yêu cầu LLM, lệnh gọi công cụ, định tuyến tin nhắn), bạn có thể khởi động gateway PicoClaw với cờ gỡ lỗi: + +```bash +picoclaw gateway --debug +# or +picoclaw gateway -d +``` + +Ở chế độ này, hệ thống sẽ định dạng log chi tiết và hiển thị bản xem trước của prompt hệ thống và kết quả thực thi công cụ. + +## Tắt cắt ngắn log (log đầy đủ) + +Theo mặc định, PicoClaw cắt ngắn các chuỗi rất dài (như *Prompt Hệ thống* hoặc kết quả JSON lớn) trong log gỡ lỗi để giữ cho console dễ đọc. + +Nếu bạn cần kiểm tra đầu ra đầy đủ của một lệnh hoặc payload chính xác được gửi đến mô hình LLM, bạn có thể sử dụng cờ `--no-truncate`. + +**Lưu ý:** Cờ này *chỉ* hoạt động khi kết hợp với chế độ `--debug`. + +```bash +picoclaw gateway --debug --no-truncate + +``` + +Khi cờ này được kích hoạt, chức năng cắt ngắn toàn cục sẽ bị vô hiệu hóa. Điều này cực kỳ hữu ích để: + +* Xác minh cú pháp chính xác của các tin nhắn được gửi đến nhà cung cấp. +* Đọc đầu ra đầy đủ của các công cụ như `exec`, `web_fetch` hoặc `read_file`. +* Gỡ lỗi lịch sử phiên được lưu trong bộ nhớ. diff --git a/docs/vi/docker.md b/docs/vi/docker.md index 519ace5ba..eddc20a75 100644 --- a/docs/vi/docker.md +++ b/docs/vi/docker.md @@ -12,6 +12,7 @@ git clone https://github.com/sipeed/picoclaw.git cd picoclaw # 2. Lần chạy đầu tiên — tự động tạo docker/data/config.json rồi thoát +# (chỉ kích hoạt khi cả config.json và workspace/ đều không tồn tại) docker compose -f docker/docker-compose.yml --profile gateway up # Container hiển thị "First-run setup complete." và dừng lại. diff --git a/docs/vi/hardware-compatibility.md b/docs/vi/hardware-compatibility.md new file mode 100644 index 000000000..8315c049e --- /dev/null +++ b/docs/vi/hardware-compatibility.md @@ -0,0 +1,152 @@ +> Quay lại [README](../../README.vi.md) + +# 🖥️ PicoClaw Danh sách tương thích phần cứng + +PicoClaw chạy được trên hầu hết mọi thiết bị Linux. Trang này ghi nhận các chip, sản phẩm và bo mạch phát triển đã được xác minh. + +**Phần cứng của bạn chưa có trong danh sách?** Gửi PR để thêm vào! Các nhà sản xuất phần cứng được hoan nghênh đóng góp và đồng quảng bá. + +--- + +## 1. Hỗ trợ chip đã xác minh + +### x86 + +| Nhà sản xuất | Chip | Ghi chú | +|--------------|------|---------| +| Intel | Any x86 CPU (i386+) | Tất cả bộ xử lý desktop/server/laptop | +| AMD | Any x86 CPU | Tất cả bộ xử lý desktop/server/laptop | + +### ARM + +| Kiến trúc phụ | Chip tiêu biểu | Ghi chú | +|----------------|----------------|---------| +| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | Đơn nhân ARM1176JZF-S | +| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | Đơn nhân Cortex-A7, dùng trong LicheePi Zero | +| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | Bốn nhân Cortex-A53, dùng trong Orange Pi Zero 3 | +| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | Bốn nhân Cortex-A72 | +| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | Bốn nhân Cortex-A76 | +| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | Hai nhân Cortex-A53 + NPU, dùng trong NanoKVM-Pro / MaixCAM2 | + +### RISC-V (riscv64) + +| Nhà sản xuất | Chip | Lõi | Ghi chú | +|--------------|------|-----|---------| +| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 tích hợp, dùng trong LicheeRV-Nano / NanoKVM / MaixCAM | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L tích hợp, 1 TOPS NPU, camera AI 4K SiP | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | Dòng camera AI RISC-V | +| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | Dùng trong HaaS506-LD1 RTU công nghiệp | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | Dùng trong Milk-V Jupiter, BananaPi BPI-F3 | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | Tuân thủ RVA23, RVV 1024-bit, suy luận AI FP8 | +| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8 lõi, 16MB cache L3, cấp desktop | +| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU, dùng trong CanMV-K230 | + +### MIPS + +| Nhà sản xuất | Chip | Ghi chú | +|--------------|------|---------| +| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz, dùng trong nhiều router OpenWrt (vd. Xiaomi Router 3G) | + +### LoongArch (loong64) + +| Nhà sản xuất | Chip | Ghi chú | +|--------------|------|---------| +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | Bốn nhân LA464 @ 2.5GHz, desktop/máy trạm | +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | Bốn nhân 4C/8T @ 2.5GHz, IPC tương đương Intel thế hệ 10 | +| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | Hai nhân @ 1GHz, ứng dụng công nghiệp/IoT | + +--- + +## 2. Sản phẩm đã xác minh (theo ngày phát hành) + +Sản phẩm tiêu dùng, router và thiết bị công nghiệp đã được kiểm thử với PicoClaw. + +| Năm | Sản phẩm | Kiến trúc | SoC | RAM | Danh mục | +|-----|----------|-----------|-----|-----|----------| +| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | Điện thoại thông minh | +| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | Máy tính bảng | +| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | Router (OpenWrt) | +| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | TV Box / Máy chủ gia đình | +| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | Loa thông minh | +| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM | +| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | RTU công nghiệp | +| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | IP-KVM Pro | +| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | Camera AI 4K | + +--- + +## 3. Bo mạch phát triển đã xác minh (theo ngày phát hành) + +| Năm | Bo mạch | Kiến trúc | SoC | RAM | Liên kết mua | +|-----|---------|-----------|-----|-----|--------------| +| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — | +| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — | +| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — | +| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — | +| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) | +| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) | +| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) | +| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) | +| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) | +| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) | +| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) | + +--- + +## 4. Cũng hoạt động trên + +### Điện thoại Android (qua Termux) + +Bất kỳ điện thoại Android ARM64 nào (2015+) với 1GB+ RAM. Cài đặt [Termux](https://github.com/termux/termux-app), sử dụng `proot` để chạy PicoClaw. + +> Xem [README: Chạy trên điện thoại Android cũ](../../README.vi.md#-run-on-old-android-phones) để biết hướng dẫn cài đặt. + +### Desktop / Máy chủ / Đám mây + +| Nền tảng | Ghi chú | +|----------|---------| +| x86_64 Linux | Binary gốc, không phụ thuộc | +| x86_64 Windows | Binary gốc | +| macOS (Intel / Apple Silicon) | Binary gốc | +| Docker (any platform) | `docker compose` một dòng lệnh, xem [Hướng dẫn Docker](docker.md) | +| OpenWrt routers | Bản dựng MIPS/ARM, yêu cầu >32MB RAM trống | +| FreeBSD / NetBSD | Có bản dựng x86_64 và arm64 | + +--- + +## 5. Yêu cầu tối thiểu + +| Tài nguyên | Tối thiểu | Khuyến nghị | +|------------|-----------|-------------| +| RAM | 10MB trống | 32MB+ trống | +| Lưu trữ | 20MB (binary) | 50MB+ (với workspace) | +| CPU | Bất kỳ (đơn nhân 0.6GHz+) | — | +| OS | Linux (kernel 3.x+) | Linux 5.x+ | +| Mạng | Bắt buộc (cho các lệnh gọi API LLM) | Ethernet hoặc WiFi | + +--- + +## 6. Cách kiểm thử và đóng góp + +```bash +# 1. Tải xuống cho kiến trúc của bạn +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz + +# 2. Khởi tạo +./picoclaw onboard + +# 3. Kiểm thử +./picoclaw agent -m "Hello, what board am I running on?" +``` + +Các bản dựng có sẵn: `linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle` + +### Thêm phần cứng của bạn + +1. Fork kho lưu trữ này +2. Thêm chip / sản phẩm / bo mạch của bạn vào bảng tương ứng +3. Bao gồm: tên, kiến trúc, SoC, RAM, năm và liên kết nếu có +4. Gửi PR + +Nhà sản xuất phần cứng: muốn thêm hỗ trợ chính thức hoặc đồng quảng bá? Mở issue hoặc liên hệ qua [Discord](https://discord.gg/V4sAZ9XWpN). diff --git a/docs/vi/providers.md b/docs/vi/providers.md index f7543eec3..09b51c56b 100644 --- a/docs/vi/providers.md +++ b/docs/vi/providers.md @@ -93,7 +93,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr ], "agents": { "defaults": { - "model": "gpt-5.4" + "model_name": "gpt-5.4" } } } @@ -266,13 +266,13 @@ Cấu hình `providers` cũ đã **ngừng hỗ trợ** nhưng vẫn được h ], "agents": { "defaults": { - "model": "glm-4.7" + "model_name": "glm-4.7" } } } ``` -Để xem hướng dẫn di chuyển chi tiết, xem [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). +Để xem hướng dẫn di chuyển chi tiết, xem [migration/model-list-migration.md](../migration/model-list-migration.md). ### Kiến Trúc Provider @@ -298,7 +298,7 @@ PicoClaw định tuyến provider theo họ giao thức: "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", + "model_name": "glm-4.7", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 @@ -328,12 +328,11 @@ picoclaw agent -m "Hello" { "agents": { "defaults": { - "model": "anthropic/claude-opus-4-5" + "model_name": "anthropic/claude-opus-4-5" } }, "session": { - "dm_scope": "per-channel-peer", - "backlog_limit": 20 + "dm_scope": "per-channel-peer" }, "providers": { "openrouter": { diff --git a/docs/vi/tools_configuration.md b/docs/vi/tools_configuration.md index 6cc4dc8b6..76a336186 100644 --- a/docs/vi/tools_configuration.md +++ b/docs/vi/tools_configuration.md @@ -70,9 +70,32 @@ Công cụ exec được sử dụng để thực thi các lệnh shell. | Cấu hình | Kiểu | Mặc định | Mô tả | |--------------------------|-------|----------|------------------------------------------------| +| `enabled` | bool | true | Bật công cụ exec | | `enable_deny_patterns` | bool | true | Bật chặn lệnh nguy hiểm mặc định | | `custom_deny_patterns` | array | [] | Mẫu từ chối tùy chỉnh (biểu thức chính quy) | +### Vô hiệu hóa Công cụ Exec + +Để hoàn toàn vô hiệu hóa công cụ `exec`, đặt `enabled` thành `false`: + +**Qua tệp cấu hình:** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**Qua biến môi trường:** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **Lưu ý:** Khi bị vô hiệu hóa, agent sẽ không thể thực thi lệnh shell. Điều này cũng ảnh hưởng đến khả năng chạy lệnh shell theo lịch của công cụ Cron. + ### Chức năng - **`enable_deny_patterns`**: Đặt thành `false` để tắt hoàn toàn các mẫu chặn lệnh nguy hiểm mặc định @@ -329,6 +352,7 @@ Tất cả các tùy chọn cấu hình có thể được ghi đè qua biến m Ví dụ: - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` diff --git a/docs/vi/troubleshooting.md b/docs/vi/troubleshooting.md index d74153aa3..961c932aa 100644 --- a/docs/vi/troubleshooting.md +++ b/docs/vi/troubleshooting.md @@ -16,7 +16,7 @@ **Cách sửa:** Trong `~/.picoclaw/config.json` (hoặc đường dẫn cấu hình của bạn): -1. **agents.defaults.model** phải khớp với một `model_name` trong `model_list` (ví dụ: `"openrouter-free"`). +1. **agents.defaults.model_name** phải khớp với một `model_name` trong `model_list` (ví dụ: `"openrouter-free"`). 2. **model** của mục đó phải là ID mô hình OpenRouter hợp lệ, ví dụ: - `"openrouter/free"` – tầng miễn phí tự động - `"google/gemini-2.0-flash-exp:free"` @@ -28,7 +28,7 @@ Ví dụ: { "agents": { "defaults": { - "model": "openrouter-free" + "model_name": "openrouter-free" } }, "model_list": [ diff --git a/docs/zh/ANTIGRAVITY_AUTH.md b/docs/zh/ANTIGRAVITY_AUTH.md new file mode 100644 index 000000000..db7c81dea --- /dev/null +++ b/docs/zh/ANTIGRAVITY_AUTH.md @@ -0,0 +1,809 @@ +> 返回 [README](../../README.zh.md) + +# Antigravity 认证与集成指南 + +## 概述 + +**Antigravity**(Google Cloud Code Assist)是由 Google 支持的 AI 模型提供商,通过 Google 的云基础设施提供对 Claude Opus 4.6 和 Gemini 等模型的访问。本文档提供了关于认证工作原理、如何获取模型以及如何在 PicoClaw 中实现新提供商的完整指南。 + +--- + +## 目录 + +1. [认证流程](#认证流程) +2. [OAuth 实现细节](#oauth-实现细节) +3. [令牌管理](#令牌管理) +4. [模型列表获取](#模型列表获取) +5. [用量追踪](#用量追踪) +6. [提供商插件结构](#提供商插件结构) +7. [集成要求](#集成要求) +8. [API 端点](#api-端点) +9. [配置](#配置) +10. [在 PicoClaw 中创建新提供商](#在-picoclaw-中创建新提供商) + +--- + +## 认证流程 + +### 1. 带 PKCE 的 OAuth 2.0 + +Antigravity 使用 **OAuth 2.0 with PKCE(Proof Key for Code Exchange)** 进行安全认证: + +``` +┌─────────────┐ ┌─────────────────┐ +│ Client │ ───(1) Generate PKCE Pair────────> │ │ +│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │ +│ │ │ Server │ +│ │ <──(3) Redirect with Code───────── │ │ +│ │ └─────────────────┘ +│ │ ───(4) Exchange Code for Tokens──> │ Token URL │ +│ │ │ │ +│ │ <──(5) Access + Refresh Tokens──── │ │ +└─────────────┘ └─────────────────┘ +``` + +### 2. 详细步骤 + +#### 步骤 1:生成 PKCE 参数 +```typescript +function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} +``` + +#### 步骤 2:构建授权 URL +```typescript +const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const REDIRECT_URI = "http://localhost:51121/oauth-callback"; + +function buildAuthUrl(params: { challenge: string; state: string }): string { + const url = new URL(AUTH_URL); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", SCOPES.join(" ")); + url.searchParams.set("code_challenge", params.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", params.state); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + return url.toString(); +} +``` + +**所需权限范围:** +```typescript +const SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", +]; +``` + +#### 步骤 3:处理 OAuth 回调 + +**自动模式(本地开发):** +- 在端口 51121 上启动本地 HTTP 服务器 +- 等待来自 Google 的重定向 +- 从查询参数中提取授权码 + +**手动模式(远程/无头环境):** +- 向用户显示授权 URL +- 用户在浏览器中完成认证 +- 用户将完整的重定向 URL 粘贴回终端 +- 从粘贴的 URL 中解析授权码 + +#### 步骤 4:用授权码交换令牌 +```typescript +const TOKEN_URL = "https://oauth2.googleapis.com/token"; + +async function exchangeCode(params: { + code: string; + verifier: string; +}): Promise<{ access: string; refresh: string; expires: number }> { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: params.code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + code_verifier: params.verifier, + }), + }); + + const data = await response.json(); + + return { + access: data.access_token, + refresh: data.refresh_token, + expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer + }; +} +``` + +#### 步骤 5:获取额外的用户数据 + +**用户邮箱:** +```typescript +async function fetchUserEmail(accessToken: string): Promise { + const response = await fetch( + "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + const data = await response.json(); + return data.email; +} +``` + +**项目 ID(API 调用必需):** +```typescript +async function fetchProjectId(accessToken: string): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": JSON.stringify({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }), + }; + + const response = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers, + body: JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + const data = await response.json(); + return data.cloudaicompanionProject || "rising-fact-p41fc"; // 默认回退值 +} +``` + +--- + +## OAuth 实现细节 + +### 客户端凭据 + +**重要:** 这些凭据在源代码中以 base64 编码存储,用于与 pi-ai 同步: + +```typescript +const decode = (s: string) => Buffer.from(s, "base64").toString(); + +const CLIENT_ID = decode( + "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==" +); +const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); +``` + +### OAuth 流程模式 + +1. **自动流程**(有浏览器的本地机器): + - 自动打开浏览器 + - 本地回调服务器捕获重定向 + - 初始认证后无需用户交互 + +2. **手动流程**(远程/无头/WSL2 环境): + - 显示 URL 供手动复制粘贴 + - 用户在外部浏览器中完成认证 + - 用户将完整的重定向 URL 粘贴回来 + +```typescript +function shouldUseManualOAuthFlow(isRemote: boolean): boolean { + return isRemote || isWSL2Sync(); +} +``` + +--- + +## 令牌管理 + +### 认证配置文件结构 + +```typescript +type OAuthCredential = { + type: "oauth"; + provider: "google-antigravity"; + access: string; // 访问令牌 + refresh: string; // 刷新令牌 + expires: number; // 过期时间戳(毫秒,自 epoch 起) + email?: string; // 用户邮箱 + projectId?: string; // Google Cloud 项目 ID +}; +``` + +### 令牌刷新 + +凭据包含一个刷新令牌,可在当前访问令牌过期时用于获取新的访问令牌。过期时间设置了 5 分钟的缓冲区以防止竞态条件。 + +--- + +## 模型列表获取 + +### 获取可用模型 + +```typescript +const BASE_URL = "https://cloudcode-pa.googleapis.com"; + +async function fetchAvailableModels( + accessToken: string, + projectId: string +): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + }; + + const response = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers, + body: JSON.stringify({ project: projectId }), + } + ); + + const data = await response.json(); + + // 返回带有配额信息的模型 + return Object.entries(data.models).map(([modelId, modelInfo]) => ({ + id: modelId, + displayName: modelInfo.displayName, + quotaInfo: { + remainingFraction: modelInfo.quotaInfo?.remainingFraction, + resetTime: modelInfo.quotaInfo?.resetTime, + isExhausted: modelInfo.quotaInfo?.isExhausted, + }, + })); +} +``` + +### 响应格式 + +```typescript +type FetchAvailableModelsResponse = { + models?: Record; +}; +``` + +--- + +## 用量追踪 + +### 获取用量数据 + +```typescript +export async function fetchAntigravityUsage( + token: string, + timeoutMs: number +): Promise { + // 1. 获取额度和计划信息 + const loadCodeAssistRes = await fetch( + `${BASE_URL}/v1internal:loadCodeAssist`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: { + ideType: "ANTIGRAVITY", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + // 提取额度信息 + const { availablePromptCredits, planInfo, currentTier } = data; + + // 2. 获取模型配额 + const modelsRes = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ project: projectId }), + } + ); + + // 构建用量窗口 + return { + provider: "google-antigravity", + displayName: "Google Antigravity", + windows: [ + { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) }, + // 各模型配额... + ], + plan: currentTier?.name || planType, + }; +} +``` + +### 用量响应结构 + +```typescript +type ProviderUsageSnapshot = { + provider: "google-antigravity"; + displayName: string; + windows: UsageWindow[]; + plan?: string; + error?: string; +}; + +type UsageWindow = { + label: string; // "Credits" 或模型 ID + usedPercent: number; // 0-100 + resetAt?: number; // 配额重置的时间戳 +}; +``` + +--- + +## 提供商插件结构 + +### 插件定义 + +```typescript +const antigravityPlugin = { + id: "google-antigravity-auth", + name: "Google Antigravity Auth", + description: "OAuth flow for Google Antigravity (Cloud Code Assist)", + configSchema: emptyPluginConfigSchema(), + + register(api: PicoClawPluginApi) { + api.registerProvider({ + id: "google-antigravity", + label: "Google Antigravity", + docsPath: "/providers/models", + aliases: ["antigravity"], + + auth: [ + { + id: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + kind: "oauth", + run: async (ctx: ProviderAuthContext) => { + // OAuth 实现在此处 + }, + }, + ], + }); + }, +}; +``` + +### ProviderAuthContext + +```typescript +type ProviderAuthContext = { + config: PicoClawConfig; + agentDir?: string; + workspaceDir?: string; + prompter: WizardPrompter; // UI 提示/通知 + runtime: RuntimeEnv; // 日志等 + isRemote: boolean; // 是否在远程运行 + openUrl: (url: string) => Promise; // 浏览器打开器 + oauth: { + createVpsAwareHandlers: Function; + }; +}; +``` + +### ProviderAuthResult + +```typescript +type ProviderAuthResult = { + profiles: Array<{ + profileId: string; + credential: AuthProfileCredential; + }>; + configPatch?: Partial; + defaultModel?: string; + notes?: string[]; +}; +``` + +--- + +## 集成要求 + +### 1. 所需环境/依赖 + +- Go ≥ 1.25 +- PicoClaw 代码库(`pkg/providers/` 和 `pkg/auth/`) +- `crypto` 和 `net/http` 标准库包 + +### 2. API 调用所需的请求头 + +```typescript +const REQUIRED_HEADERS = { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", // 或 "google-api-nodejs-client/9.15.1" + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", +}; + +// 对于 loadCodeAssist 调用,还需包含: +const CLIENT_METADATA = { + ideType: "ANTIGRAVITY", // 或 "IDE_UNSPECIFIED" + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", +}; +``` + +### 3. 模型 Schema 清理 + +Antigravity 使用兼容 Gemini 的模型,因此工具 schema 必须进行清理: + +```typescript +const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([ + "patternProperties", + "additionalProperties", + "$schema", + "$id", + "$ref", + "$defs", + "definitions", + "examples", + "minLength", + "maxLength", + "minimum", + "maximum", + "multipleOf", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems", + "minProperties", + "maxProperties", +]); + +// 发送前清理 schema +function cleanToolSchemaForGemini(schema: Record): unknown { + // 移除不支持的关键字 + // 确保顶层有 type: "object" + // 展平 anyOf/oneOf 联合类型 +} +``` + +### 4. 思维块处理(Claude 模型) + +对于 Antigravity 的 Claude 模型,思维块需要特殊处理: + +```typescript +const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +export function sanitizeAntigravityThinkingBlocks( + messages: AgentMessage[] +): AgentMessage[] { + // 验证思维签名 + // 规范化签名字段 + // 丢弃未签名的思维块 +} +``` + +--- + +## API 端点 + +### 认证端点 + +| 端点 | 方法 | 用途 | +|------|------|------| +| `https://accounts.google.com/o/oauth2/v2/auth` | GET | OAuth 授权 | +| `https://oauth2.googleapis.com/token` | POST | 令牌交换 | +| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | 用户信息(邮箱) | + +### Cloud Code Assist 端点 + +| 端点 | 方法 | 用途 | +|------|------|------| +| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | 加载项目信息、额度、计划 | +| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | 列出可用模型及配额 | +| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | 聊天流式端点 | + +**API 请求格式(聊天):** +`v1internal:streamGenerateContent` 端点期望一个包装标准 Gemini 请求的信封格式: + +```json +{ + "project": "your-project-id", + "model": "model-id", + "request": { + "contents": [...], + "systemInstruction": {...}, + "generationConfig": {...}, + "tools": [...] + }, + "requestType": "agent", + "userAgent": "antigravity", + "requestId": "agent-timestamp-random" +} +``` + +**API 响应格式(SSE):** +每条 SSE 消息(`data: {...}`)被包装在 `response` 字段中: + +```json +{ + "response": { + "candidates": [...], + "usageMetadata": {...}, + "modelVersion": "...", + "responseId": "..." + }, + "traceId": "...", + "metadata": {} +} +``` + +--- + +## 配置 + +### config.json 配置 + +```json +{ + "model_list": [ + { + "model_name": "gemini-flash", + "model": "antigravity/gemini-3-flash", + "auth_method": "oauth" + } + ], + "agents": { + "defaults": { + "model_name": "gemini-flash" + } + } +} +``` + +### 认证配置文件存储 + +认证配置文件存储在 `~/.picoclaw/auth.json` 中: + +```json +{ + "credentials": { + "google-antigravity": { + "access_token": "ya29...", + "refresh_token": "1//...", + "expires_at": "2026-01-01T00:00:00Z", + "provider": "google-antigravity", + "auth_method": "oauth", + "email": "user@example.com", + "project_id": "my-project-id" + } + } +} +``` + +--- + +## 在 PicoClaw 中创建新提供商 + +PicoClaw 提供商以 Go 包的形式实现,位于 `pkg/providers/` 下。要添加新提供商: + +### 分步实现 + +#### 1. 创建提供商文件 + +在 `pkg/providers/` 中创建新的 Go 文件: + +``` +pkg/providers/ +└── your_provider.go +``` + +#### 2. 实现 Provider 接口 + +你的提供商必须实现 `pkg/providers/types.go` 中定义的 `Provider` 接口: + +```go +package providers + +type YourProvider struct { + apiKey string + apiBase string +} + +func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider { + if apiBase == "" { + apiBase = "https://api.your-provider.com/v1" + } + return &YourProvider{apiKey: apiKey, apiBase: apiBase} +} + +func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error { + // 实现带流式传输的聊天补全 +} +``` + +#### 3. 在工厂中注册 + +将你的提供商添加到 `pkg/providers/factory.go` 中的协议分支: + +```go +case "your-provider": + return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil +``` + +#### 4. 添加默认配置(可选) + +在 `pkg/config/defaults.go` 中添加默认条目: + +```go +{ + ModelName: "your-model", + Model: "your-provider/model-name", + APIKey: "", +}, +``` + +#### 5. 添加认证支持(可选) + +如果你的提供商需要 OAuth 或特殊认证,在 `cmd/picoclaw/internal/auth/helpers.go` 中添加分支: + +```go +case "your-provider": + authLoginYourProvider() +``` + +#### 6. 通过 `config.json` 配置 + +```json +{ + "model_list": [ + { + "model_name": "your-model", + "model": "your-provider/model-name", + "api_key": "your-api-key", + "api_base": "https://api.your-provider.com/v1" + } + ] +} +``` + +--- + +## 测试你的实现 + +### CLI 命令 + +```bash +# 使用提供商进行认证 +picoclaw auth login --provider your-provider + +# 列出模型(用于 Antigravity) +picoclaw auth models + +# 启动网关 +picoclaw gateway + +# 使用指定模型运行代理 +picoclaw agent -m "Hello" --model your-model +``` + +### 测试用环境变量 + +```bash +# 覆盖默认模型 +export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model + +# 覆盖提供商设置 +export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]' +``` + +--- + +## 参考资料 + +- **源文件:** + - `pkg/providers/antigravity_provider.go` - Antigravity 提供商实现 + - `pkg/auth/oauth.go` - OAuth 流程实现 + - `pkg/auth/store.go` - 认证凭据存储(`~/.picoclaw/auth.json`) + - `pkg/providers/factory.go` - 提供商工厂和协议路由 + - `pkg/providers/types.go` - 提供商接口定义 + - `cmd/picoclaw/internal/auth/helpers.go` - 认证 CLI 命令 + +- **文档:** + - `docs/ANTIGRAVITY_USAGE.md` - Antigravity 使用指南 + - `docs/migration/model-list-migration.md` - 迁移指南 + +--- + +## 注意事项 + +1. **Google Cloud 项目:** Antigravity 要求在你的 Google Cloud 项目上启用 Gemini for Google Cloud +2. **配额:** 使用 Google Cloud 项目配额(非独立计费) +3. **模型访问:** 可用模型取决于你的 Google Cloud 项目配置 +4. **思维块:** 通过 Antigravity 使用的 Claude 模型需要对带签名的思维块进行特殊处理 +5. **Schema 清理:** 工具 schema 必须清理以移除不支持的 JSON Schema 关键字 + +--- + +--- + +## 常见错误处理 + +### 1. 速率限制(HTTP 429) + +当项目/模型配额耗尽时,Antigravity 会返回 429 错误。错误响应通常在 `details` 字段中包含 `quotaResetDelay`。 + +**429 错误示例:** +```json +{ + "error": { + "code": 429, + "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.", + "status": "RESOURCE_EXHAUSTED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "metadata": { + "quotaResetDelay": "4h30m28.060903746s" + } + } + ] + } +} +``` + +### 2. 空响应(受限模型) + +某些模型可能出现在可用模型列表中,但返回空响应(200 OK 但 SSE 流为空)。这通常发生在当前项目没有权限使用的预览版或受限模型上。 + +**处理方式:** 将空响应视为错误,通知用户该模型可能对其项目受限或无效。 + +--- + +## 故障排除 + +### "Token expired"(令牌已过期) +- 刷新 OAuth 令牌:`picoclaw auth login --provider antigravity` + +### "Gemini for Google Cloud is not enabled"(Gemini for Google Cloud 未启用) +- 在 Google Cloud Console 中启用该 API + +### "Project not found"(项目未找到) +- 确保你的 Google Cloud 项目已启用必要的 API +- 检查认证过程中项目 ID 是否正确获取 + +### 模型未出现在列表中 +- 验证 OAuth 认证是否成功完成 +- 检查认证配置文件存储:`~/.picoclaw/auth.json` +- 重新运行 `picoclaw auth login --provider antigravity` diff --git a/docs/zh/ANTIGRAVITY_USAGE.md b/docs/zh/ANTIGRAVITY_USAGE.md new file mode 100644 index 000000000..2218618a9 --- /dev/null +++ b/docs/zh/ANTIGRAVITY_USAGE.md @@ -0,0 +1,72 @@ +> 返回 [README](../../README.zh.md) + +# 在 PicoClaw 中使用 Antigravity 提供商 + +本指南介绍如何在 PicoClaw 中设置和使用 **Antigravity**(Google Cloud Code Assist)提供商。 + +## 前提条件 + +1. 一个 Google 账户。 +2. 已启用 Google Cloud Code Assist(通常通过"Gemini for Google Cloud"引导流程获取)。 + +## 1. 身份验证 + +要使用 Antigravity 进行身份验证,请运行以下命令: + +```bash +picoclaw auth login --provider antigravity +``` + +### 手动验证(无界面/VPS 环境) +如果你在服务器(Coolify/Docker)上运行且无法访问 `localhost`,请按照以下步骤操作: +1. 运行上述命令。 +2. 复制提供的 URL 并在本地浏览器中打开。 +3. 完成登录。 +4. 浏览器将重定向到 `localhost:51121` URL(页面将无法加载)。 +5. **从浏览器地址栏复制该最终 URL**。 +6. **将其粘贴回 PicoClaw 正在等待的终端中**。 + +PicoClaw 将自动提取授权码并完成流程。 + +## 2. 管理模型 + +### 列出可用模型 +查看你的项目可以访问哪些模型并检查其配额: + +```bash +picoclaw auth models +``` + +### 切换模型 +你可以在 `~/.picoclaw/config.json` 中更改默认模型,或通过 CLI 覆盖: + +```bash +# 为单个命令覆盖 +picoclaw agent -m "Hello" --model claude-opus-4-6-thinking +``` + +## 3. 实际使用(Coolify/Docker) + +如果你通过 Coolify 或 Docker 部署,请按照以下步骤进行测试: + +1. **环境变量**: + * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash` +2. **身份验证持久化**: + 如果你已在本地登录,可以将凭据复制到服务器: + ```bash + scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/ + ``` + *或者*,如果你有终端访问权限,可以在服务器上运行一次 `auth login` 命令。 + +## 4. 故障排除 + +* **空响应**:如果模型返回空回复,可能是该模型在你的项目中受到限制。请尝试 `gemini-3-flash` 或 `claude-opus-4-6-thinking`。 +* **429 速率限制**:Antigravity 有严格的配额限制。如果触发限制,PicoClaw 将在错误消息中显示"重置时间"。 +* **404 未找到**:确保你使用的是 `picoclaw auth models` 列表中的模型 ID。请使用短 ID(例如 `gemini-3-flash`),而非完整路径。 + +## 5. 可用模型总结 + +根据测试,以下模型最为可靠: +* `gemini-3-flash`(快速,高可用性) +* `gemini-2.5-flash-lite`(轻量级) +* `claude-opus-4-6-thinking`(强大,包含推理能力) diff --git a/docs/zh/chat-apps.md b/docs/zh/chat-apps.md index 4957fbcca..a0206a7d6 100644 --- a/docs/zh/chat-apps.md +++ b/docs/zh/chat-apps.md @@ -14,7 +14,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方 | -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](../channels/telegram/README.zh.md) | | **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](../channels/discord/README.zh.md) | -| **WhatsApp** | ⭐ 简单 | 原生 (QR 扫码) 或 Bridge URL | [查看文档](../channels/whatsapp/README.zh.md) | +| **WhatsApp** | ⭐ 简单 | 原生 (QR 扫码) 或 Bridge URL | [查看文档](#whatsapp) | | **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](../channels/slack/README.zh.md) | | **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](../channels/matrix/README.zh.md) | | **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](../channels/qq/README.zh.md) | @@ -207,12 +207,13 @@ picoclaw gateway
QQ -**1. 创建 Bot** +**快速设置(推荐)** -- 前往 [QQ 开放平台](https://q.qq.com/#) -- 创建应用 → 获取 **AppID** 和 **AppSecret** +QQ 开放平台提供了一键创建 OpenClaw 兼容机器人的页面: -**2. 配置** +1. 打开 [QQ 机器人快速创建](https://q.qq.com/qqbot/openclaw/index.html),扫码登录 +2. 机器人自动创建 — 复制 **App ID** 和 **App Secret** +3. 配置 PicoClaw: ```json { @@ -227,13 +228,20 @@ picoclaw gateway } ``` -> `allow_from` 留空表示允许所有用户,或指定 QQ 号限制访问。 +4. 运行 `picoclaw gateway`,打开 QQ 与机器人聊天 -**3. 运行** +> App Secret 仅显示一次,请立即保存 — 再次查看将强制重置。 +> +> 通过快速创建页面创建的机器人初始仅限创建者使用,不支持群聊。如需启用群聊访问,请在 [QQ 开放平台](https://q.qq.com/) 配置沙箱模式。 -```bash -picoclaw gateway -``` +**手动设置** + +如果你更喜欢手动创建机器人: + +* 登录 [QQ 开放平台](https://q.qq.com/) 注册成为开发者 +* 创建 QQ 机器人 — 自定义头像和名称 +* 从机器人设置中复制 **App ID** 和 **App Secret** +* 按上述方式配置并运行 `picoclaw gateway`
@@ -242,9 +250,10 @@ picoclaw gateway **1. 创建 Slack App** -* 前往 [Slack API](https://api.slack.com/apps) 创建应用 -* 启用 **Socket Mode** -* 获取 **Bot Token** 和 **App-Level Token** +* 前往 [Slack API](https://api.slack.com/apps) 创建新应用 +* 在 **OAuth & Permissions** 中添加 Bot 权限范围:`chat:write`、`app_mentions:read`、`im:history`、`im:read`、`im:write` +* 将应用安装到你的工作区 +* 复制 **Bot Token**(`xoxb-...`)和 **App-Level Token**(`xapp-...`,启用 Socket Mode 后获取) **2. 配置** @@ -253,8 +262,8 @@ picoclaw gateway "channels": { "slack": { "enabled": true, - "bot_token": "xoxb-YOUR_BOT_TOKEN", - "app_token": "xapp-YOUR_APP_TOKEN", + "bot_token": "xoxb-YOUR-BOT-TOKEN", + "app_token": "xapp-YOUR-APP-TOKEN", "allow_from": [] } } @@ -280,21 +289,26 @@ picoclaw gateway "irc": { "enabled": true, "server": "irc.libera.chat:6697", + "tls": true, "nick": "picoclaw-bot", - "use_tls": true, - "channels_to_join": ["#your-channel"], + "channels": ["#your-channel"], + "password": "", "allow_from": [] } } } ``` +可选:`nickserv_password` 用于 NickServ 认证,`sasl_user`/`sasl_password` 用于 SASL 认证。 + **2. 运行** ```bash picoclaw gateway ``` +Bot 将连接到 IRC 服务器并加入指定的频道。 +
@@ -382,11 +396,14 @@ picoclaw gateway
飞书 (Feishu) +PicoClaw 通过 WebSocket/SDK 模式连接飞书 — 无需公网 Webhook URL 或回调服务器。 + **1. 创建应用** -* 前往 [飞书开放平台](https://open.feishu.cn/) -* 创建企业自建应用 -* 获取 **App ID** 和 **App Secret** +* 前往 [飞书开放平台](https://open.feishu.cn/) 创建应用 +* 在应用设置中启用 **机器人** 能力 +* 创建版本并发布应用(应用必须发布后才能生效) +* 复制 **App ID**(以 `cli_` 开头)和 **App Secret** **2. 配置** @@ -396,21 +413,25 @@ picoclaw gateway "feishu": { "enabled": true, "app_id": "cli_xxx", - "app_secret": "xxx", - "encrypt_key": "", - "verification_token": "", + "app_secret": "YOUR_APP_SECRET", "allow_from": [] } } } ``` -**3. 运行** +可选:`encrypt_key` 和 `verification_token` 用于事件加密(生产环境推荐)。 + +**3. 运行并聊天** ```bash picoclaw gateway ``` +打开飞书,搜索你的机器人名称即可开始聊天。也可以将机器人添加到群组 — 使用 `group_trigger.mention_only: true` 设置为仅在 @提及时回复。 + +完整选项请参考 [飞书渠道配置指南](../channels/feishu/README.zh.md)。 +
@@ -496,7 +517,7 @@ picoclaw gateway **1. 创建 AI Bot** * 企业微信管理后台 → 应用管理 → AI Bot -* 在 AI Bot 设置中配置回调 URL:`http://your-server:18791/webhook/wecom-aibot` +* 在 AI Bot 设置中配置回调 URL:`http://your-server:18790/webhook/wecom-aibot` * 复制 **Token** 并点击"随机生成" **EncodingAESKey** **2. 配置** @@ -510,7 +531,8 @@ picoclaw gateway "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], - "welcome_message": "你好!有什么可以帮你的?" + "welcome_message": "你好!有什么可以帮你的?", + "processing_message": "⏳ Processing, please wait. The results will be sent shortly." } } } @@ -527,24 +549,36 @@ picoclaw gateway
-OneBot +OneBot(通过 OneBot 协议连接 QQ) -**1. 配置** +OneBot 是 QQ 机器人的开放协议。PicoClaw 通过 WebSocket 连接任何 OneBot v11 兼容实现(如 [Lagrange](https://github.com/LagrangeDev/Lagrange.Core)、[NapCat](https://github.com/NapNeko/NapCatQQ))。 -兼容 NapCat / Go-CQHTTP 等 OneBot 实现。 +**1. 设置 OneBot 实现** + +安装并运行 OneBot v11 兼容的 QQ 机器人框架,启用其 WebSocket 服务器。 + +**2. 配置** ```json { "channels": { "onebot": { "enabled": true, + "ws_url": "ws://127.0.0.1:8080", + "access_token": "", "allow_from": [] } } } ``` -**2. 运行** +| 字段 | 说明 | +|------|------| +| `ws_url` | OneBot 实现的 WebSocket URL | +| `access_token` | 认证用的访问令牌(如果在 OneBot 中配置了的话) | +| `reconnect_interval` | 重连间隔(秒)(默认:5) | + +**3. 运行** ```bash picoclaw gateway diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index a2bf8fce2..68fb1fd1a 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -57,7 +57,7 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work 1. `~/.picoclaw/workspace/skills`(工作区) 2. `~/.picoclaw/skills`(全局) -3. `/skills`(内置) +3. `<构建时嵌入路径>/skills`(内置) 在高级/测试场景下,可通过以下环境变量覆盖内置技能目录: diff --git a/docs/zh/credential_encryption.md b/docs/zh/credential_encryption.md new file mode 100644 index 000000000..2105e4307 --- /dev/null +++ b/docs/zh/credential_encryption.md @@ -0,0 +1,158 @@ +> 返回 [README](../../README.zh.md) + +# 凭据加密 + +PicoClaw 支持对 `model_list` 配置条目中的 `api_key` 值进行加密。 +加密后的密钥以 `enc://` 字符串形式存储,并在启动时自动解密。 + +--- + +## 快速开始 + +**1. 设置密码短语** + +```bash +export PICOCLAW_KEY_PASSPHRASE="your-passphrase" +``` + +**2. 加密 API 密钥** + +运行 `picoclaw onboard` — 它会提示你输入密码短语并生成 SSH 密钥, +然后在下一次 `SaveConfig` 调用时自动重新加密配置中所有明文 `api_key` 条目。生成的 `enc://` 值如下所示: + +``` +enc://AAAA...base64... +``` + +**3. 将输出粘贴到你的配置中** + +```json +{ + "model_list": [ + { + "model_name": "gpt-4o", + "model": "openai/gpt-4o", + "api_key": "enc://AAAA...base64...", + "api_base": "https://api.openai.com/v1" + } + ] +} +``` + +--- + +## 支持的 `api_key` 格式 + +| 格式 | 示例 | 行为 | +|------|------|------| +| 明文 | `sk-abc123` | 直接使用 | +| 文件引用 | `file://openai.key` | 从配置文件所在目录读取内容 | +| 加密 | `enc://` | 启动时使用 `PICOCLAW_KEY_PASSPHRASE` 解密 | +| 空值 | `""` | 原样传递(用于 `auth_method: oauth`) | + +--- + +## 加密设计 + +### 密钥派生 + +加密使用 **HKDF-SHA256**,并以 SSH 私钥作为第二因子。 + +``` +sshHash = SHA256(ssh_private_key_file_bytes) +ikm = HMAC-SHA256(key=sshHash, message=passphrase) +aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +``` + +### 加密 + +``` +AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key) +``` + +### 传输格式 + +``` +enc:// +``` + +| 字段 | 大小 | 描述 | +|------|------|------| +| `salt` | 16 字节 | 每次加密随机生成;输入 HKDF | +| `nonce` | 12 字节 | 每次加密随机生成;AES-GCM IV | +| `ciphertext` | 可变 | AES-256-GCM 密文 + 16 字节认证标签 | + +GCM 认证标签会自动附加到密文之后。任何篡改都会导致解密失败并报错,而不是返回损坏的明文。 + +### 性能 + +| 操作 | 耗时 (ARM Cortex-A) | +|------|---------------------| +| 密钥派生 (HKDF) | < 1 ms | +| AES-256-GCM 解密 | < 1 ms | +| **启动总开销** | **每个密钥 < 2 ms** | + +--- + +## 使用 SSH 密钥的双因子安全 + +当提供 SSH 私钥时,破解加密需要**同时具备**: + +1. **密码短语** (`PICOCLAW_KEY_PASSPHRASE`) +2. **SSH 私钥文件** + +这意味着仅泄露配置文件不足以恢复 API 密钥,即使密码短语较弱也是如此。SSH 密钥贡献 256 位熵(Ed25519),与密码短语强度无关。 + +### 威胁模型 + +| 攻击者拥有 | 能否解密? | +|------------|-----------| +| 仅配置文件 | 否 — 需要密码短语 + SSH 密钥 | +| 仅 SSH 密钥 | 否 — 需要密码短语 | +| 仅密码短语 | 否 — 需要 SSH 密钥 | +| 配置文件 + SSH 密钥 + 密码短语 | 是 — 完全泄露 | + +--- + +## 环境变量 + +| 变量 | 是否必需 | 描述 | +|------|----------|------| +| `PICOCLAW_KEY_PASSPHRASE` | 是(用于 `enc://`) | 用于密钥派生的密码短语 | +| `PICOCLAW_SSH_KEY_PATH` | 否 | SSH 私钥路径。如未设置,自动从 `~/.ssh/picoclaw_ed25519.key` 检测 | + +### SSH 密钥自动检测 + +如果未设置 `PICOCLAW_SSH_KEY_PATH`,PicoClaw 会查找专用密钥: + +``` +~/.ssh/picoclaw_ed25519.key +``` + +此专用文件避免与用户现有的 SSH 密钥冲突。 +运行 `picoclaw onboard` 可自动生成该密钥。 + +`os.UserHomeDir()` 用于跨平台主目录解析(在 Windows 上读取 `USERPROFILE`,在 Unix/macOS 上读取 `HOME`)。 + +> **注意:** SSH 密钥文件是凭据加密的必要条件。如果未找到密钥且未设置 `PICOCLAW_SSH_KEY_PATH`,加密/解密将失败。运行 `picoclaw onboard` 可自动生成密钥。 + +--- + +## 迁移 + +由于唯一的密钥材料是 `PICOCLAW_KEY_PASSPHRASE` 和 SSH 私钥文件,迁移非常简单: + +1. 将配置文件复制到新机器。 +2. 将 `PICOCLAW_KEY_PASSPHRASE` 设置为相同的值。 +3. 将 SSH 私钥文件复制到相同路径(或将 `PICOCLAW_SSH_KEY_PATH` 设置为新位置)。 + +无需重新加密。 + +--- + +## 安全注意事项 + +- **密码短语和 SSH 密钥都是必需的。** SSH 密钥作为第二因子 — 没有它,加密/解密将失败。如果密钥不存在,运行 `picoclaw onboard` 生成。 +- **SSH 密钥在运行时为只读。** PicoClaw 不会写入或修改 SSH 密钥文件。 +- **仍然支持明文密钥。** 不使用 `enc://` 的现有配置不受影响。 +- **`enc://` 格式通过版本控制**,通过 HKDF `info` 字段(`picoclaw-credential-v1`)实现,允许未来升级算法而不破坏现有加密值。 diff --git a/docs/zh/debug.md b/docs/zh/debug.md new file mode 100644 index 000000000..e7f20d777 --- /dev/null +++ b/docs/zh/debug.md @@ -0,0 +1,36 @@ +# 调试 PicoClaw + +> 返回 [README](../../README.zh.md) + +PicoClaw 在处理每一个请求时,都会在后台执行多个复杂的交互操作——从消息路由和复杂度评估,到工具执行和模型故障适配。能够准确地看到正在发生什么至关重要,这不仅有助于排查潜在问题,也有助于真正理解代理的运作方式。 + +## 以调试模式启动 PicoClaw + +要获取代理运行的详细信息(LLM 请求、工具调用、消息路由),可以使用调试标志启动 PicoClaw 网关: + +```bash +picoclaw gateway --debug +# or +picoclaw gateway -d +``` + +在此模式下,系统会对日志进行详细格式化,并显示系统提示词和工具执行结果的预览。 + +## 禁用日志截断(完整日志) + +默认情况下,PicoClaw 会在调试日志中截断过长的字符串(例如*系统提示词*或大型 JSON 输出结果),以保持控制台的可读性。 + +如果你需要检查某个命令的完整输出,或发送给 LLM 模型的确切载荷,可以使用 `--no-truncate` 标志。 + +**注意:** 此标志*仅*在与 `--debug` 模式组合使用时有效。 + +```bash +picoclaw gateway --debug --no-truncate + +``` + +当此标志激活时,全局截断功能将被禁用。这在以下场景中非常有用: + +* 验证发送给提供商的消息的确切语法。 +* 读取 `exec`、`web_fetch` 或 `read_file` 等工具的完整输出。 +* 调试保存在内存中的会话历史。 diff --git a/docs/zh/docker.md b/docs/zh/docker.md index d2e582d12..10bc46544 100644 --- a/docs/zh/docker.md +++ b/docs/zh/docker.md @@ -12,6 +12,7 @@ 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 gateway up # 容器打印 "First-run setup complete." 后自动停止 diff --git a/docs/zh/hardware-compatibility.md b/docs/zh/hardware-compatibility.md new file mode 100644 index 000000000..66bd08072 --- /dev/null +++ b/docs/zh/hardware-compatibility.md @@ -0,0 +1,152 @@ +> 返回 [README](../../README.zh.md) + +# 🖥️ PicoClaw 硬件兼容性列表 + +PicoClaw 几乎可以在任何 Linux 设备上运行。本页面记录了已验证的芯片、产品和开发板。 + +**你的硬件不在列表中?** 提交 PR 来添加它!欢迎硬件厂商贡献和联合推广。 + +--- + +## 1. 已验证的芯片支持 + +### x86 + +| 厂商 | 芯片 | 备注 | +|------|------|------| +| Intel | Any x86 CPU (i386+) | 所有桌面/服务器/笔记本处理器 | +| AMD | Any x86 CPU | 所有桌面/服务器/笔记本处理器 | + +### ARM + +| 子架构 | 典型芯片 | 备注 | +|--------|----------|------| +| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | 单核 ARM1176JZF-S | +| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | 单核 Cortex-A7,用于 LicheePi Zero | +| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | 四核 Cortex-A53,用于 Orange Pi Zero 3 | +| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | 四核 Cortex-A72 | +| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | 四核 Cortex-A76 | +| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | 双核 Cortex-A53 + NPU,用于 NanoKVM-Pro / MaixCAM2 | + +### RISC-V (riscv64) + +| 厂商 | 芯片 | 核心 | 备注 | +|------|------|------|------| +| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 片上内存,用于 LicheeRV-Nano / NanoKVM / MaixCAM | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L 片上内存,1 TOPS NPU,4K AI 摄像头 SiP | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | RISC-V AI 摄像头系列 | +| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | 用于 HaaS506-LD1 工业 RTU | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | 用于 Milk-V Jupiter, BananaPi BPI-F3 | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | 符合 RVA23 规范,1024 位 RVV,FP8 AI 推理 | +| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8 核,16MB L3 缓存,桌面级 | +| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU,用于 CanMV-K230 | + +### MIPS + +| 厂商 | 芯片 | 备注 | +|------|------|------| +| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz,用于许多 OpenWrt 路由器(如小米路由器 3G) | + +### LoongArch (loong64) + +| 厂商 | 芯片 | 备注 | +|------|------|------| +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | 四核 LA464 @ 2.5GHz,桌面/工作站 | +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | 四核 4C/8T @ 2.5GHz,IPC 可与 Intel 第十代相媲美 | +| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | 双核 @ 1GHz,工业/物联网应用 | + +--- + +## 2. 已验证的产品(按发布日期排列) + +已通过 PicoClaw 测试的消费产品、路由器和工业设备。 + +| 年份 | 产品 | 架构 | SoC | 内存 | 类别 | +|------|------|------|-----|------|------| +| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | 智能手机 | +| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | 平板电脑 | +| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | 路由器 (OpenWrt) | +| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | 电视盒子 / 家庭服务器 | +| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | 智能音箱 | +| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM | +| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | 工业 RTU | +| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | 专业 IP-KVM | +| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | 4K AI 摄像头 | + +--- + +## 3. 已验证的开发板(按发布日期排列) + +| 年份 | 开发板 | 架构 | SoC | 内存 | 购买链接 | +|------|--------|------|-----|------|----------| +| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — | +| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — | +| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — | +| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — | +| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) | +| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) | +| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) | +| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) | +| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) | +| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) | +| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) | + +--- + +## 4. 同样适用于 + +### Android 手机(通过 Termux) + +任何 ARM64 Android 手机(2015 年以后),1GB 以上内存。安装 [Termux](https://github.com/termux/termux-app),使用 `proot` 运行 PicoClaw。 + +> 参见 [README:在旧 Android 手机上运行](../../README.zh.md#-run-on-old-android-phones) 获取设置说明。 + +### 桌面 / 服务器 / 云 + +| 平台 | 备注 | +|------|------| +| x86_64 Linux | 原生二进制文件,无依赖 | +| x86_64 Windows | 原生二进制文件 | +| macOS (Intel / Apple Silicon) | 原生二进制文件 | +| Docker (any platform) | `docker compose` 一行命令,参见 [Docker 指南](docker.md) | +| OpenWrt routers | MIPS/ARM 构建,需要 >32MB 可用内存 | +| FreeBSD / NetBSD | 提供 x86_64 和 arm64 构建 | + +--- + +## 5. 最低要求 + +| 资源 | 最低要求 | 推荐配置 | +|------|----------|----------| +| 内存 | 10MB 可用 | 32MB 以上可用 | +| 存储 | 20MB(二进制文件) | 50MB 以上(含工作区) | +| CPU | 任意(单核 0.6GHz 以上) | — | +| 操作系统 | Linux (kernel 3.x+) | Linux 5.x+ | +| 网络 | 必需(用于 LLM API 调用) | 以太网或 WiFi | + +--- + +## 6. 如何测试与贡献 + +```bash +# 1. 下载适合你架构的版本 +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz + +# 2. 初始化 +./picoclaw onboard + +# 3. 测试 +./picoclaw agent -m "Hello, what board am I running on?" +``` + +可用构建版本:`linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle` + +### 添加你的硬件 + +1. Fork 本仓库 +2. 将你的芯片/产品/开发板添加到相应的表格中 +3. 包含:名称、架构、SoC、内存、年份,以及可用的链接 +4. 提交 PR + +硬件厂商:想要添加官方支持或联合推广?请提交 issue 或通过 [Discord](https://discord.gg/V4sAZ9XWpN) 联系我们。 diff --git a/docs/zh/providers.md b/docs/zh/providers.md index 5b7a4cc2a..9092e7dfe 100644 --- a/docs/zh/providers.md +++ b/docs/zh/providers.md @@ -93,7 +93,7 @@ ], "agents": { "defaults": { - "model": "gpt-5.4" + "model_name": "gpt-5.4" } } } @@ -266,7 +266,7 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l ], "agents": { "defaults": { - "model": "glm-4.7" + "model_name": "glm-4.7" } } } @@ -298,7 +298,7 @@ PicoClaw 按协议族路由 Provider: "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", + "model_name": "glm-4.7", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 @@ -328,12 +328,11 @@ picoclaw agent -m "你好" { "agents": { "defaults": { - "model": "anthropic/claude-opus-4-5" + "model_name": "anthropic/claude-opus-4-5" } }, "session": { - "dm_scope": "per-channel-peer", - "backlog_limit": 20 + "dm_scope": "per-channel-peer" }, "providers": { "openrouter": { diff --git a/docs/zh/spawn-tasks.md b/docs/zh/spawn-tasks.md index c6721fceb..781462af2 100644 --- a/docs/zh/spawn-tasks.md +++ b/docs/zh/spawn-tasks.md @@ -2,13 +2,15 @@ > 返回 [README](../../README.zh.md) -### 使用 Spawn 的异步任务 +PicoClaw 通过 `spawn` 工具支持**异步任务执行**。主要由 **Heartbeat(心跳)** 系统使用,在不阻塞主 Agent 循环的情况下运行耗时任务。 -对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**: +## Heartbeat + +心跳系统会定期检查 `workspace/HEARTBEAT.md` 中的计划任务。首次运行时会自动生成默认模板,你可以自定义它来定义快速任务(内联处理)和长任务(通过 `spawn` 委派)。 + +**`HEARTBEAT.md` 示例:** ```markdown -# Periodic Tasks - ## Quick Tasks (respond directly) - Report current time diff --git a/docs/zh/tools_configuration.md b/docs/zh/tools_configuration.md index ff88b6707..f13448952 100644 --- a/docs/zh/tools_configuration.md +++ b/docs/zh/tools_configuration.md @@ -43,11 +43,12 @@ Web 工具用于网页搜索和抓取。 ### Brave -| 配置项 | 类型 | 默认值 | 描述 | -|---------------|--------|--------|--------------------| -| `enabled` | bool | false | 启用 Brave 搜索 | -| `api_key` | string | - | Brave Search API 密钥 | -| `max_results` | int | 5 | 最大结果数 | +| 配置项 | 类型 | 默认值 | 描述 | +|---------------|----------|--------|------------------------------------------------| +| `enabled` | bool | false | 启用 Brave 搜索 | +| `api_key` | string | - | Brave Search API 密钥 | +| `api_keys` | string[] | - | 多个 API 密钥轮换(优先于 `api_key`) | +| `max_results` | int | 5 | 最大结果数 | ### DuckDuckGo @@ -58,11 +59,46 @@ Web 工具用于网页搜索和抓取。 ### 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[] | - | 多个 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 搜索 | +| `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 | 最大结果数 | + +### 其他 Web 设置 + +| 配置项 | 类型 | 默认值 | 描述 | +|--------------------------|----------|--------|-------------------------------------------------| +| `prefer_native` | bool | true | 优先使用 provider 原生搜索而非配置的搜索引擎 | +| `private_host_whitelist` | string[] | `[]` | 允许 Web 抓取的私有/内部主机白名单 | ## Exec 工具 @@ -70,9 +106,32 @@ Exec 工具用于执行 shell 命令。 | 配置项 | 类型 | 默认值 | 描述 | |------------------------|-------|--------|--------------------------------| +| `enabled` | bool | true | 启用 exec 工具 | | `enable_deny_patterns` | bool | true | 启用默认的危险命令拦截 | | `custom_deny_patterns` | array | [] | 自定义拒绝模式(正则表达式) | +### 禁用 Exec 工具 + +要完全禁用 `exec` 工具,请将 `enabled` 设置为 `false`: + +**通过配置文件:** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**通过环境变量:** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **注意:** 禁用后,代理将无法执行 shell 命令。这也会影响 Cron 工具运行计划 shell 命令的能力。 + ### 功能说明 - **`enable_deny_patterns`**:设为 `false` 可完全禁用默认的危险命令拦截模式 @@ -131,6 +190,7 @@ Cron 工具用于调度周期性任务。 | 配置项 | 类型 | 默认值 | 描述 | |------------------------|------|--------|-------------------------------------| | `exec_timeout_minutes` | int | 5 | 执行超时时间(分钟),0 表示无限制 | +| `allow_command` | bool | false | 允许 cron 任务执行 shell 命令 | ## MCP 工具 @@ -297,9 +357,27 @@ Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。 | `registries.clawhub.enabled` | bool | true | 启用 ClawHub 注册表 | | `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub 基础 URL | | `registries.clawhub.auth_token` | string | `""` | 可选的 Bearer 令牌,用于更高速率限制 | -| `registries.clawhub.search_path` | string | `/api/v1/search` | 搜索 API 路径 | -| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API 路径 | -| `registries.clawhub.download_path` | string | `/api/v1/download` | 下载 API 路径 | +| `registries.clawhub.search_path` | string | `""` | 搜索 API 路径 | +| `registries.clawhub.skills_path` | string | `""` | Skills API 路径 | +| `registries.clawhub.download_path` | string | `""` | 下载 API 路径 | +| `registries.clawhub.timeout` | int | 0 | 请求超时时间(秒),0 = 默认 | +| `registries.clawhub.max_zip_size` | int | 0 | 技能 zip 最大大小(字节),0 = 默认 | +| `registries.clawhub.max_response_size` | int | 0 | API 响应最大大小(字节),0 = 默认 | + +### GitHub 集成 + +| 配置项 | 类型 | 默认值 | 描述 | +|------------------|--------|--------|-------------------------------| +| `github.proxy` | string | `""` | GitHub API 请求的 HTTP 代理 | +| `github.token` | string | `""` | GitHub 个人访问令牌 | + +### 搜索设置 + +| 配置项 | 类型 | 默认值 | 描述 | +|----------------------------|------|--------|--------------------------| +| `max_concurrent_searches` | int | 2 | 最大并发技能搜索请求数 | +| `search_cache.max_size` | int | 50 | 最大缓存搜索结果数 | +| `search_cache.ttl_seconds` | int | 300 | 缓存 TTL(秒) | ### 配置示例 @@ -311,11 +389,17 @@ Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。 "clawhub": { "enabled": true, "base_url": "https://clawhub.ai", - "auth_token": "", - "search_path": "/api/v1/search", - "skills_path": "/api/v1/skills", - "download_path": "/api/v1/download" + "auth_token": "" } + }, + "github": { + "proxy": "", + "token": "" + }, + "max_concurrent_searches": 2, + "search_cache": { + "max_size": 50, + "ttl_seconds": 300 } } } @@ -329,6 +413,7 @@ Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。 例如: - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` diff --git a/docs/zh/troubleshooting.md b/docs/zh/troubleshooting.md index a3329ee35..be4d4f5d7 100644 --- a/docs/zh/troubleshooting.md +++ b/docs/zh/troubleshooting.md @@ -16,7 +16,7 @@ **修复方法:** 在 `~/.picoclaw/config.json`(或你的配置路径)中: -1. **agents.defaults.model** 必须匹配 `model_list` 中的某个 `model_name`(例如 `"openrouter-free"`)。 +1. **agents.defaults.model_name** 必须匹配 `model_list` 中的某个 `model_name`(例如 `"openrouter-free"`)。 2. 该条目的 **model** 必须是有效的 OpenRouter 模型 ID,例如: - `"openrouter/free"` – 自动免费层 - `"google/gemini-2.0-flash-exp:free"` @@ -28,7 +28,7 @@ { "agents": { "defaults": { - "model": "openrouter-free" + "model_name": "openrouter-free" } }, "model_list": [ diff --git a/examples/pico-echo-server/README.md b/examples/pico-echo-server/README.md new file mode 100644 index 000000000..f6b5d8020 --- /dev/null +++ b/examples/pico-echo-server/README.md @@ -0,0 +1,47 @@ +# pico-echo-server + +Minimal Pico Protocol WebSocket server for testing the `pico_client` channel. + +## Usage + +```bash +go run ./examples/pico-echo-server -addr :9090 -token secret +``` + +### Flags + +| Flag | Default | Description | +|----------|---------|------------------------------------| +| `-addr` | `:9090` | Listen address | +| `-token` | (none) | Auth token; empty disables auth | + +## How it works + +- Listens for WebSocket connections at `/ws` +- Authenticates via `Authorization: Bearer ` header or `?token=` query param +- Prints received `message.send` content to stdout +- Responds to `ping` with `pong` +- Lines typed into stdin are broadcast as `message.create` to all connected clients + +## Testing with pico_client + +1. Start the server: + ```bash + go run ./examples/pico-echo-server -token mytoken + ``` + +2. Configure `pico_client` in your `config.json`: + ```json + { + "channels": { + "pico_client": { + "enabled": true, + "url": "ws://localhost:9090/ws", + "token": "mytoken", + "session_id": "test-session" + } + } + } + ``` + +3. Start picoclaw — the client connects and you can exchange messages interactively via stdin/stdout. diff --git a/examples/pico-echo-server/main.go b/examples/pico-echo-server/main.go new file mode 100644 index 000000000..46970fb34 --- /dev/null +++ b/examples/pico-echo-server/main.go @@ -0,0 +1,160 @@ +// pico-echo-server is a minimal Pico Protocol WebSocket server for testing +// the pico_client channel. It accepts connections, prints received messages +// to stdout, and forwards stdin lines as message.create to all connected clients. +// +// Usage: +// +// go run ./examples/pico-echo-server -addr :9090 -token secret +// +// Then configure pico_client with url=ws://localhost:9090/ws&token=secret. +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +type picoMessage struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + SessionID string `json:"session_id,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + Payload map[string]any `json:"payload,omitempty"` +} + +var upgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + +type server struct { + token string + mu sync.Mutex + conns map[*websocket.Conn]string // conn → sessionID +} + +func (s *server) handleWS(w http.ResponseWriter, r *http.Request) { + if s.token != "" { + auth := r.Header.Get("Authorization") + if auth != "Bearer "+s.token { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("upgrade: %v", err) + return + } + + sessionID := r.URL.Query().Get("session_id") + if sessionID == "" { + sessionID = fmt.Sprintf("sess-%d", time.Now().UnixMilli()) + } + + s.mu.Lock() + s.conns[conn] = sessionID + s.mu.Unlock() + + log.Printf("[+] client connected (session=%s)", sessionID) + + defer func() { + s.mu.Lock() + delete(s.conns, conn) + s.mu.Unlock() + conn.Close() + log.Printf("[-] client disconnected (session=%s)", sessionID) + }() + + for { + _, raw, err := conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + log.Printf("read error: %v", err) + } + return + } + + var msg picoMessage + if err := json.Unmarshal(raw, &msg); err != nil { + log.Printf("bad json: %v", err) + continue + } + + switch msg.Type { + case "ping": + pong := picoMessage{Type: "pong", ID: msg.ID, Timestamp: time.Now().UnixMilli()} + conn.WriteJSON(pong) + + case "message.send": + content, _ := msg.Payload["content"].(string) + fmt.Printf("[%s] %s\n", sessionID, content) + + case "typing.start": + log.Printf("[%s] typing...", sessionID) + + case "typing.stop": + log.Printf("[%s] stopped typing", sessionID) + + default: + log.Printf("[%s] unknown type: %s", sessionID, msg.Type) + } + } +} + +func (s *server) broadcast(content string) { + msg := picoMessage{ + Type: "message.create", + Timestamp: time.Now().UnixMilli(), + Payload: map[string]any{"content": content}, + } + + s.mu.Lock() + defer s.mu.Unlock() + + for conn, sid := range s.conns { + msg.SessionID = sid + if err := conn.WriteJSON(msg); err != nil { + log.Printf("write to %s failed: %v", sid, err) + } + } +} + +func main() { + addr := flag.String("addr", ":9090", "listen address") + token := flag.String("token", "", "auth token (empty = no auth)") + flag.Parse() + + s := &server{ + token: *token, + conns: make(map[*websocket.Conn]string), + } + + http.HandleFunc("/ws", s.handleWS) + + log.Printf("listening on %s", *addr) + log.Printf("connect with: ws://localhost%s/ws", *addr) + fmt.Println("Type messages to send to connected clients (Ctrl+C to quit):") + + go func() { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + s.broadcast(line) + log.Printf("[server] sent: %s", line) + } + }() + + log.Fatal(http.ListenAndServe(*addr, nil)) +} diff --git a/go.mod b/go.mod index 8c52895f2..d283f7f5e 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,15 @@ module github.com/sipeed/picoclaw -go 1.25.7 +go 1.25.8 require ( 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 github.com/caarlos0/env/v11 v11.4.0 - github.com/ergochat/irc-go v0.5.0 + github.com/ergochat/irc-go v0.6.0 github.com/ergochat/readline v0.1.3 github.com/gdamore/tcell/v2 v2.13.8 github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab @@ -17,7 +18,7 @@ require ( github.com/h2non/filetype v1.1.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mdp/qrterminal/v3 v3.2.1 - github.com/modelcontextprotocol/go-sdk v1.3.1 + github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/mymmrac/telego v1.7.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/openai/openai-go/v3 v3.22.0 @@ -29,16 +30,16 @@ require ( github.com/tencent-connect/botgo v0.2.1 go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 golang.org/x/oauth2 v0.36.0 - golang.org/x/term v0.40.0 + golang.org/x/term v0.41.0 golang.org/x/time v0.14.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.26.3 + maunium.net/go/mautrix v0.26.4 modernc.org/sqlite v1.46.1 ) require ( - filippo.io/edwards25519 v1.1.1 // indirect + filippo.io/edwards25519 v1.2.0 // indirect github.com/beeper/argo-go v1.1.2 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -51,18 +52,18 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect + github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/segmentio/asm v1.1.3 // indirect - github.com/segmentio/encoding v0.5.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/vektah/gqlparser/v2 v2.5.27 // indirect go.mau.fi/libsignal v0.2.1 // indirect - go.mau.fi/util v0.9.6 // indirect - golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect - golang.org/x/text v0.34.0 // indirect + go.mau.fi/util v0.9.7 // indirect + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect + golang.org/x/text v0.35.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect @@ -92,8 +93,8 @@ require ( github.com/valyala/fastjson v1.6.10 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/arch v0.24.0 // indirect - golang.org/x/crypto v0.48.0 - golang.org/x/net v0.51.0 - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect + golang.org/x/crypto v0.49.0 + golang.org/x/net v0.52.0 + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect ) diff --git a/go.sum b/go.sum index f0e3fc132..f24b997d4 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,10 @@ cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= -filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= -filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= @@ -46,8 +48,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= -github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw= -github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= +github.com/ergochat/irc-go v0.6.0 h1:Y0AGV76aeihJfCtLaQh+OyJKFiKGrYC0VTkeMZ6XW28= +github.com/ergochat/irc-go v0.6.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4pSo= github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -70,8 +72,8 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -140,8 +142,8 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= -github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= -github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= +github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= +github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo= github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -158,8 +160,8 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys= github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= -github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= -github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE= +github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -179,8 +181,8 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= -github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= -github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= @@ -235,8 +237,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= -go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts= -go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI= +go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg= +go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE= go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ= go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= @@ -250,16 +252,16 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= -golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -273,8 +275,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -284,8 +286,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -307,15 +309,15 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -323,8 +325,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -334,8 +336,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -365,8 +367,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk= -maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38= +maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs= +maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 1c3635322..355e78a33 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -3,13 +3,13 @@ package agent import ( "context" "fmt" - "log" "os" "path/filepath" "regexp" "strings" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/providers" @@ -85,9 +85,11 @@ func NewAgentInstance( if cfg.Tools.IsToolEnabled("exec") { execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg, allowReadPaths) if err != nil { - log.Fatalf("Critical error: unable to initialize exec tool: %v", err) + logger.ErrorCF("agent", "Failed to initialize exec tool; continuing without exec", + map[string]any{"error": err.Error()}) + } else { + toolsRegistry.Register(execTool) } - toolsRegistry.Register(execTool) } if cfg.Tools.IsToolEnabled("edit_file") { @@ -150,59 +152,14 @@ func NewAgentInstance( } // Resolve fallback candidates - modelCfg := providers.ModelConfig{ - Primary: model, - Fallbacks: fallbacks, - } - resolveFromModelList := func(raw string) (string, bool) { - ensureProtocol := func(model string) string { - model = strings.TrimSpace(model) - if model == "" { - return "" - } - if strings.Contains(model, "/") { - return model - } - return "openai/" + model - } - - raw = strings.TrimSpace(raw) - if raw == "" { - return "", false - } - - if cfg != nil { - if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" { - return ensureProtocol(mc.Model), true - } - - for i := range cfg.ModelList { - fullModel := strings.TrimSpace(cfg.ModelList[i].Model) - if fullModel == "" { - continue - } - if fullModel == raw { - return ensureProtocol(fullModel), true - } - _, modelID := providers.ExtractProtocol(fullModel) - if modelID == raw { - return ensureProtocol(fullModel), true - } - } - } - - return "", false - } - - candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList) + candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks) // Model routing setup: pre-resolve light model candidates at creation time // to avoid repeated model_list lookups on every incoming message. var router *routing.Router var lightCandidates []providers.FallbackCandidate if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" { - lightModelCfg := providers.ModelConfig{Primary: rc.LightModel} - resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList) + resolved := resolveModelCandidates(cfg, defaults.Provider, rc.LightModel, nil) if len(resolved) > 0 { router = routing.New(routing.RouterConfig{ LightModel: rc.LightModel, @@ -210,8 +167,8 @@ func NewAgentInstance( }) lightCandidates = resolved } else { - log.Printf("routing: light_model %q not found in model_list — routing disabled for agent %q", - rc.LightModel, agentID) + logger.WarnCF("agent", "Routing light model not found; routing disabled", + map[string]any{"light_model": rc.LightModel, "agent_id": agentID}) } } @@ -320,7 +277,8 @@ func (a *AgentInstance) Close() error { func initSessionStore(dir string) session.SessionStore { store, err := memory.NewJSONLStore(dir) if err != nil { - log.Printf("memory: init store: %v; using json sessions", err) + logger.WarnCF("agent", "Memory JSONL store init failed; falling back to json sessions", + map[string]any{"error": err.Error()}) return session.NewSessionManager(dir) } @@ -328,11 +286,12 @@ func initSessionStore(dir string) session.SessionStore { // Migration failure means the store could not write data. // Fall back to SessionManager to avoid a split state where // some sessions are in JSONL and others remain in JSON. - log.Printf("memory: migration failed: %v; falling back to json sessions", merr) + logger.WarnCF("agent", "Memory migration failed; falling back to json sessions", + map[string]any{"error": merr.Error()}) store.Close() return session.NewSessionManager(dir) } else if n > 0 { - log.Printf("memory: migrated %d session(s) to jsonl", n) + logger.InfoCF("agent", "Memory migrated to JSONL", map[string]any{"sessions_migrated": n}) } return session.NewJSONLBackend(store) diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index 1ea919478..e073cb929 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -246,3 +246,37 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) { t.Fatalf("exec output missing media content: %s", execResult.ForLLM) } } + +func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) { + workspace := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "test-model", + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{Enabled: true}, + Exec: config.ExecConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + EnableDenyPatterns: true, + CustomDenyPatterns: []string{"[invalid-regex"}, + }, + }, + } + + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{}) + if agent == nil { + t.Fatal("expected agent instance, got nil") + } + + if _, ok := agent.Tools.Get("exec"); ok { + t.Fatal("exec tool should not be registered when exec config is invalid") + } + + if _, ok := agent.Tools.Get("read_file"); !ok { + t.Fatal("read_file tool should still be registered") + } +} diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 9d7a0f3ef..03a46897f 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -70,7 +70,8 @@ type processOptions struct { } const ( - defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." + defaultResponse = "The model returned an empty response. This may indicate a provider error or token limit." + toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps." sessionKeyAgentPrefix = "agent:" metadataKeyAccountID = "account_id" metadataKeyGuildID = "guild_id" @@ -292,58 +293,64 @@ func (al *AgentLoop) Run(ctx context.Context) error { return nil } // Process message - // TODO: Re-enable media cleanup after inbound media is properly consumed by the agent. - // Currently disabled because files are deleted before the LLM can access their content. - // defer func() { - // if al.mediaStore != nil && msg.MediaScope != "" { - // if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil { - // logger.WarnCF("agent", "Failed to release media", map[string]any{ - // "scope": msg.MediaScope, - // "error": releaseErr.Error(), - // }) - // } - // } - // }() + func() { + defer func() { + if al.channelManager != nil { + al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID) + } + }() + // TODO: Re-enable media cleanup after inbound media is properly consumed by the agent. + // Currently disabled because files are deleted before the LLM can access their content. + // defer func() { + // if al.mediaStore != nil && msg.MediaScope != "" { + // if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil { + // logger.WarnCF("agent", "Failed to release media", map[string]any{ + // "scope": msg.MediaScope, + // "error": releaseErr.Error(), + // }) + // } + // } + // }() - response, err := al.processMessage(ctx, msg) - if err != nil { - response = fmt.Sprintf("Error processing message: %v", err) - } + response, err := al.processMessage(ctx, msg) + if err != nil { + response = fmt.Sprintf("Error processing message: %v", err) + } - if response != "" { - // Check if the message tool already sent a response during this round. - // If so, skip publishing to avoid duplicate messages to the user. - // Use default agent's tools to check (message tool is shared). - alreadySent := false - defaultAgent := al.GetRegistry().GetDefaultAgent() - if defaultAgent != nil { - if tool, ok := defaultAgent.Tools.Get("message"); ok { - if mt, ok := tool.(*tools.MessageTool); ok { - alreadySent = mt.HasSentInRound() + if response != "" { + // Check if the message tool already sent a response during this round. + // If so, skip publishing to avoid duplicate messages to the user. + // Use default agent's tools to check (message tool is shared). + alreadySent := false + defaultAgent := al.GetRegistry().GetDefaultAgent() + if defaultAgent != nil { + if tool, ok := defaultAgent.Tools.Get("message"); ok { + if mt, ok := tool.(*tools.MessageTool); ok { + alreadySent = mt.HasSentInRound() + } } } - } - - if !alreadySent { - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: msg.Channel, - ChatID: msg.ChatID, - Content: response, - }) - logger.InfoCF("agent", "Published outbound response", - map[string]any{ - "channel": msg.Channel, - "chat_id": msg.ChatID, - "content_len": len(response), + if !alreadySent { + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: msg.Channel, + ChatID: msg.ChatID, + Content: response, }) - } else { - logger.DebugCF( - "agent", - "Skipped outbound (message tool already sent)", - map[string]any{"channel": msg.Channel}, - ) + logger.InfoCF("agent", "Published outbound response", + map[string]any{ + "channel": msg.Channel, + "chat_id": msg.ChatID, + "content_len": len(response), + }) + } else { + logger.DebugCF( + "agent", + "Skipped outbound (message tool already sent)", + map[string]any{"channel": msg.Channel}, + ) + } } - } + }() default: time.Sleep(time.Microsecond * 200) } @@ -943,7 +950,11 @@ func (al *AgentLoop) runAgentLoop( // 4. Handle empty response if finalContent == "" { - finalContent = opts.DefaultResponse + if iteration >= agent.MaxIterations && agent.MaxIterations > 0 { + finalContent = toolLimitResponse + } else { + finalContent = opts.DefaultResponse + } } // 5. Save final assistant message to session @@ -1034,6 +1045,7 @@ func (al *AgentLoop) handleReasoning( } // runLLMIteration executes the LLM call loop with tool handling. +// Returns (finalContent, iteration, error). func (al *AgentLoop) runLLMIteration( ctx context.Context, agent *AgentInstance, @@ -1043,6 +1055,13 @@ func (al *AgentLoop) runLLMIteration( iteration := 0 var finalContent string + // Check if both the provider and channel support streaming + streamProvider, providerCanStream := agent.Provider.(providers.StreamingProvider) + var streamer bus.Streamer + if providerCanStream && !opts.NoHistory && !constants.IsInternalChannel(opts.Channel) { + streamer, _ = al.bus.GetStreamer(ctx, opts.Channel, opts.ChatID) + } + // Determine effective model tier for this conversation turn. // selectCandidates evaluates routing once and the decision is sticky for // all tool-follow-up iterations within the same turn so that a multi-step @@ -1124,6 +1143,16 @@ func (al *AgentLoop) runLLMIteration( al.activeRequests.Add(1) defer al.activeRequests.Done() + // Use streaming when available (streamer obtained, provider supports it) + if streamer != nil && streamProvider != nil { + return streamProvider.ChatStream( + ctx, messages, providerToolDefs, activeModel, llmOpts, + func(accumulated string) { + streamer.Update(ctx, accumulated) + }, + ) + } + if len(activeCandidates) > 1 && al.fallback != nil { fbResult, fbErr := al.fallback.Execute( ctx, @@ -1251,15 +1280,31 @@ func (al *AgentLoop) runLLMIteration( if finalContent == "" && response.ReasoningContent != "" { finalContent = response.ReasoningContent } + + // If we were streaming, finalize the message (sends the permanent message) + if streamer != nil { + if err := streamer.Finalize(ctx, finalContent); err != nil { + logger.WarnCF("agent", "Stream finalize failed", map[string]any{ + "error": err.Error(), + }) + } + } + logger.InfoCF("agent", "LLM response without tool calls (direct answer)", map[string]any{ "agent_id": agent.ID, "iteration": iteration, "content_chars": len(finalContent), + "streamed": streamer != nil, }) break } + // Tool calls detected — cancel any active stream (draft auto-expires) + if streamer != nil { + streamer.Cancel(ctx) + } + normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) for _, tc := range response.ToolCalls { normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) @@ -1336,6 +1381,22 @@ func (al *AgentLoop) runLLMIteration( "iteration": iteration, }) + // Send tool feedback to chat channel if enabled + if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && opts.Channel != "" { + feedbackPreview := utils.Truncate( + string(argsJSON), + al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), + ) + feedbackMsg := fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", tc.Name, feedbackPreview) + fbCtx, fbCancel := context.WithTimeout(ctx, 3*time.Second) + _ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: feedbackMsg, + }) + fbCancel() + } + // Create async callback for tools that implement AsyncExecutor. // When the background work completes, this publishes the result // as an inbound system message so processSystemMessage routes it @@ -1475,7 +1536,7 @@ func (al *AgentLoop) selectCandidates( history []providers.Message, ) (candidates []providers.FallbackCandidate, model string) { if agent.Router == nil || len(agent.LightCandidates) == 0 { - return agent.Candidates, agent.Model + return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model) } _, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model) @@ -1486,7 +1547,7 @@ func (al *AgentLoop) selectCandidates( "score": score, "threshold": agent.Router.Threshold(), }) - return agent.Candidates, agent.Model + return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model) } logger.InfoCF("agent", "Model routing: light model selected", @@ -1496,7 +1557,7 @@ func (al *AgentLoop) selectCandidates( "score": score, "threshold": agent.Router.Threshold(), }) - return agent.LightCandidates, agent.Router.LightModel() + return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()) } // maybeSummarize triggers summarization if the session history exceeds thresholds. @@ -1959,11 +2020,37 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt } if agent != nil { rt.GetModelInfo = func() (string, string) { - return agent.Model, cfg.Agents.Defaults.Provider + return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider) } rt.SwitchModel = func(value string) (string, error) { + value = strings.TrimSpace(value) + modelCfg, err := resolvedModelConfig(cfg, value, agent.Workspace) + if err != nil { + return "", err + } + + nextProvider, _, err := providers.CreateProviderFromConfig(modelCfg) + if err != nil { + return "", fmt.Errorf("failed to initialize model %q: %w", value, err) + } + + nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, modelCfg.Model, agent.Fallbacks) + if len(nextCandidates) == 0 { + return "", fmt.Errorf("model %q did not resolve to any provider candidates", value) + } + oldModel := agent.Model + oldProvider := agent.Provider agent.Model = value + agent.Provider = nextProvider + agent.Candidates = nextCandidates + agent.ThinkingLevel = parseThinkingLevel(modelCfg.ThinkingLevel) + + if oldProvider != nil && oldProvider != nextProvider { + if stateful, ok := oldProvider.(providers.StatefulProvider); ok { + stateful.Close() + } + } return oldModel, nil } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index f79722686..d02bdb23d 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -2,7 +2,10 @@ package agent import ( "context" + "encoding/json" "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "slices" @@ -417,6 +420,29 @@ func (m *countingMockProvider) GetDefaultModel() string { return "counting-mock-model" } +type toolLimitOnlyProvider struct{} + +func (m *toolLimitOnlyProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{{ + ID: "call_tool_limit_test", + Type: "function", + Name: "tool_limit_test_tool", + Arguments: map[string]any{"value": "x"}, + }}, + }, nil +} + +func (m *toolLimitOnlyProvider) GetDefaultModel() string { + return "tool-limit-only-model" +} + // mockCustomTool is a simple mock tool for registration testing type mockCustomTool struct{} @@ -439,11 +465,74 @@ func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tool return tools.SilentResult("Custom tool executed") } +type toolLimitTestTool struct{} + +func (m *toolLimitTestTool) Name() string { + return "tool_limit_test_tool" +} + +func (m *toolLimitTestTool) Description() string { + return "Tool used to exhaust the iteration budget in tests" +} + +func (m *toolLimitTestTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "value": map[string]any{"type": "string"}, + }, + } +} + +func (m *toolLimitTestTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { + return tools.SilentResult("tool limit test result") +} + // testHelper executes a message and returns the response type testHelper struct { al *AgentLoop } +func newChatCompletionTestServer( + t *testing.T, + label string, + response string, + calls *int, + model *string, +) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("%s server path = %q, want /chat/completions", label, r.URL.Path) + } + *calls = *calls + 1 + defer r.Body.Close() + + var req struct { + Model string `json:"model"` + } + decodeErr := json.NewDecoder(r.Body).Decode(&req) + if decodeErr != nil { + t.Fatalf("decode %s request: %v", label, decodeErr) + } + *model = req.Model + + w.Header().Set("Content-Type", "application/json") + encodeErr := json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": response}, + "finish_reason": "stop", + }, + }, + }) + if encodeErr != nil { + t.Fatalf("encode %s response: %v", label, encodeErr) + } + })) +} + func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, msg bus.InboundMessage) string { // Use a short timeout to avoid hanging timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout) @@ -605,12 +694,34 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { Defaults: config.AgentDefaults{ Workspace: tmpDir, Provider: "openai", - ModelName: "before-switch", + ModelName: "local", MaxTokens: 4096, MaxToolIterations: 10, }, }, + ModelList: []*config.ModelConfig{ + { + ModelName: "local", + Model: "openai/local-model", + APIBase: "https://local.example.invalid/v1", + }, + { + ModelName: "deepseek", + Model: "openrouter/deepseek/deepseek-v3.2", + 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"} @@ -621,13 +732,13 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { Channel: "telegram", SenderID: "user1", ChatID: "chat1", - Content: "/switch model to after-switch", + Content: "/switch model to deepseek", Peer: bus.Peer{ Kind: "direct", ID: "user1", }, }) - if !strings.Contains(switchResp, "Switched model from before-switch to after-switch") { + if !strings.Contains(switchResp, "Switched model from local to deepseek") { t.Fatalf("unexpected /switch reply: %q", switchResp) } @@ -641,7 +752,7 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { ID: "user1", }, }) - if !strings.Contains(showResp, "Current Model: after-switch (Provider: openai)") { + if !strings.Contains(showResp, "Current Model: deepseek (Provider: openrouter)") { t.Fatalf("unexpected /show model reply after switch: %q", showResp) } @@ -650,6 +761,201 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { } } +func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Provider: "openai", + ModelName: "local", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "local", + Model: "openai/local-model", + 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"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "/switch model to missing", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + }) + if switchResp != `model "missing" not found in model_list or providers` { + t.Fatalf("unexpected /switch error reply: %q", switchResp) + } + + showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "/show model", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + }) + if !strings.Contains(showResp, "Current Model: local (Provider: openai)") { + t.Fatalf("unexpected /show model reply after rejected switch: %q", showResp) + } + + if provider.calls != 0 { + t.Fatalf("LLM should not be called for rejected /switch and /show, calls=%d", provider.calls) + } +} + +func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + localCalls := 0 + localModel := "" + localServer := newChatCompletionTestServer(t, "local", "local reply", &localCalls, &localModel) + defer localServer.Close() + + remoteCalls := 0 + remoteModel := "" + remoteServer := newChatCompletionTestServer(t, "remote", "remote reply", &remoteCalls, &remoteModel) + defer remoteServer.Close() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Provider: "openai", + ModelName: "local", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "local", + Model: "openai/Qwen3.5-35B-A3B", + APIBase: localServer.URL, + }, + { + ModelName: "deepseek", + Model: "openrouter/deepseek/deepseek-v3.2", + 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) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + firstResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hello before switch", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + }) + if firstResp != "local reply" { + t.Fatalf("unexpected response before switch: %q", firstResp) + } + if localCalls != 1 { + t.Fatalf("local calls before switch = %d, want 1", localCalls) + } + if remoteCalls != 0 { + t.Fatalf("remote calls before switch = %d, want 0", remoteCalls) + } + if localModel != "Qwen3.5-35B-A3B" { + t.Fatalf("local model before switch = %q, want %q", localModel, "Qwen3.5-35B-A3B") + } + + switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "/switch model to deepseek", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + }) + if !strings.Contains(switchResp, "Switched model from local to deepseek") { + t.Fatalf("unexpected /switch reply: %q", switchResp) + } + + secondResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hello after switch", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + }) + if secondResp != "remote reply" { + t.Fatalf("unexpected response after switch: %q", secondResp) + } + if localCalls != 1 { + t.Fatalf("local calls after switch = %d, want 1", localCalls) + } + if remoteCalls != 1 { + t.Fatalf("remote calls after switch = %d, want 1", remoteCalls) + } + if remoteModel != "deepseek-v3.2" { + t.Fatalf( + "remote model after switch = %q, want %q", + remoteModel, + "deepseek-v3.2", + ) + } +} + // TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") @@ -845,6 +1151,89 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { } } +func TestAgentLoop_EmptyModelResponseUsesAccurateFallback(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 3, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &simpleMockProvider{response: ""} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "empty-response", "test", "chat1") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + if response != defaultResponse { + t.Fatalf("response = %q, want %q", response, defaultResponse) + } +} + +func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 1, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolLimitOnlyProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + al.RegisterTool(&toolLimitTestTool{}) + + response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "tool-limit", "test", "chat1") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + if response != toolLimitResponse { + t.Fatalf("response = %q, want %q", response, toolLimitResponse) + } + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("No default agent found") + } + route := al.registry.ResolveRoute(routing.RouteInput{ + Channel: "test", + Peer: &routing.RoutePeer{ + Kind: "direct", + ID: "cron", + }, + }) + history := defaultAgent.Sessions.GetHistory(route.SessionKey) + if len(history) != 4 { + t.Fatalf("history len = %d, want 4", len(history)) + } + assertRoles(t, history, "user", "assistant", "tool", "assistant") + if history[3].Content != toolLimitResponse { + t.Fatalf("final assistant content = %q, want %q", history[3].Content, toolLimitResponse) + } +} + // TestProcessDirectWithChannel_TriggersMCPInitialization verifies that // ProcessDirectWithChannel triggers MCP initialization when MCP is enabled. // Note: Manager is only initialized when at least one MCP server is configured diff --git a/pkg/agent/model_resolution.go b/pkg/agent/model_resolution.go new file mode 100644 index 000000000..140cff718 --- /dev/null +++ b/pkg/agent/model_resolution.go @@ -0,0 +1,97 @@ +package agent + +import ( + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func buildModelListResolver(cfg *config.Config) func(raw string) (string, bool) { + ensureProtocol := func(model string) string { + model = strings.TrimSpace(model) + if model == "" { + return "" + } + if strings.Contains(model, "/") { + return model + } + return "openai/" + model + } + + return func(raw string) (string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" || cfg == nil { + return "", false + } + + if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" { + return ensureProtocol(mc.Model), true + } + + for i := range cfg.ModelList { + fullModel := strings.TrimSpace(cfg.ModelList[i].Model) + if fullModel == "" { + continue + } + if fullModel == raw { + return ensureProtocol(fullModel), true + } + _, modelID := providers.ExtractProtocol(fullModel) + if modelID == raw { + return ensureProtocol(fullModel), true + } + } + + return "", false + } +} + +func resolveModelCandidates( + cfg *config.Config, + defaultProvider string, + primary string, + fallbacks []string, +) []providers.FallbackCandidate { + return providers.ResolveCandidatesWithLookup( + providers.ModelConfig{ + Primary: primary, + Fallbacks: fallbacks, + }, + defaultProvider, + buildModelListResolver(cfg), + ) +} + +func resolvedCandidateModel(candidates []providers.FallbackCandidate, fallback string) string { + if len(candidates) > 0 && strings.TrimSpace(candidates[0].Model) != "" { + return candidates[0].Model + } + return fallback +} + +func resolvedCandidateProvider(candidates []providers.FallbackCandidate, fallback string) string { + if len(candidates) > 0 && strings.TrimSpace(candidates[0].Provider) != "" { + return candidates[0].Provider + } + return fallback +} + +func resolvedModelConfig(cfg *config.Config, modelName, workspace string) (*config.ModelConfig, error) { + if cfg == nil { + return nil, fmt.Errorf("config is nil") + } + + modelCfg, err := cfg.GetModelConfig(strings.TrimSpace(modelName)) + if err != nil { + return nil, err + } + + clone := *modelCfg + if clone.Workspace == "" { + clone.Workspace = workspace + } + + return &clone, nil +} diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index 3d08bda4f..37fcb74c5 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -14,15 +14,32 @@ var ErrBusClosed = errors.New("message bus closed") const defaultBusBufferSize = 64 +// StreamDelegate is implemented by the channel Manager to provide streaming +// capabilities to the agent loop without tight coupling. +type StreamDelegate interface { + // GetStreamer returns a Streamer for the given channel+chatID if the channel + // supports streaming. Returns nil, false if streaming is unavailable. + GetStreamer(ctx context.Context, channel, chatID string) (Streamer, bool) +} + +// Streamer pushes incremental content to a streaming-capable channel. +// Defined here so the agent loop can use it without importing pkg/channels. +type Streamer interface { + Update(ctx context.Context, content string) error + Finalize(ctx context.Context, content string) error + Cancel(ctx context.Context) +} + type MessageBus struct { inbound chan InboundMessage outbound chan OutboundMessage outboundMedia chan OutboundMediaMessage - closeOnce sync.Once - done chan struct{} - closed atomic.Bool - wg sync.WaitGroup + closeOnce sync.Once + done chan struct{} + closed atomic.Bool + wg sync.WaitGroup + streamDelegate atomic.Value // stores StreamDelegate } func NewMessageBus() *MessageBus { @@ -86,6 +103,19 @@ func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage { return mb.outboundMedia } +// SetStreamDelegate registers a StreamDelegate (typically the channel Manager). +func (mb *MessageBus) SetStreamDelegate(d StreamDelegate) { + mb.streamDelegate.Store(d) +} + +// GetStreamer returns a Streamer for the given channel+chatID via the delegate. +func (mb *MessageBus) GetStreamer(ctx context.Context, channel, chatID string) (Streamer, bool) { + if d, ok := mb.streamDelegate.Load().(StreamDelegate); ok && d != nil { + return d.GetStreamer(ctx, channel, chatID) + } + return nil, false +} + func (mb *MessageBus) Close() { mb.closeOnce.Do(func() { // notify all blocked publishers to exit diff --git a/pkg/channels/base.go b/pkg/channels/base.go index edb5b6f08..882e72d08 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -275,14 +275,18 @@ func (c *BaseChannel) HandleMessage( // Auto-trigger typing indicator, message reaction, and placeholder before publishing. // Each capability is independent — all three may fire for the same message. + // Note: even when streaming is available, we still show typing + placeholder on inbound. + // If streaming actually activates, preSend will skip the placeholder edit (streamActive map) + // and the typing stop will still be called. This avoids the problem of compile-time interface + // checks incorrectly skipping indicators when streaming may not work at runtime. if c.owner != nil && c.placeholderRecorder != nil { - // Typing — independent pipeline + // Typing if tc, ok := c.owner.(TypingCapable); ok { if stop, err := tc.StartTyping(ctx, chatID); err == nil { c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop) } } - // Reaction — independent pipeline + // Reaction if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" { if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil { c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo) diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go index fbe085b73..4952394b7 100644 --- a/pkg/channels/feishu/common.go +++ b/pkg/channels/feishu/common.go @@ -84,3 +84,64 @@ func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) s content = mentionPlaceholderRegex.ReplaceAllString(content, "") return strings.TrimSpace(content) } + +// extractCardImageKeys recursively extracts all image keys from a Feishu interactive card. +// Image keys are used to download images from Feishu API. +// Returns two slices: Feishu-hosted keys and external URLs. +func extractCardImageKeys(rawContent string) (feishuKeys []string, externalURLs []string) { + if rawContent == "" { + return nil, nil + } + + var card map[string]any + if err := json.Unmarshal([]byte(rawContent), &card); err != nil { + return nil, nil + } + + extractImageKeysRecursive(card, &feishuKeys, &externalURLs) + return feishuKeys, externalURLs +} + +// isExternalURL returns true if the string is an external HTTP/HTTPS URL. +func isExternalURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + +// extractImageKeysRecursive traverses card structure to find all image keys. +// Collects both Feishu-hosted keys and external URLs separately. +func extractImageKeysRecursive(v any, feishuKeys, externalURLs *[]string) { + switch val := v.(type) { + case map[string]any: + // Check if this is an img element + if tag, ok := val["tag"].(string); ok { + switch tag { + case "img": + // Try img_key first (always Feishu-hosted) + if imgKey, ok := val["img_key"].(string); ok && imgKey != "" { + *feishuKeys = append(*feishuKeys, imgKey) + } + // Check src - could be Feishu key or external URL + if src, ok := val["src"].(string); ok && src != "" { + if isExternalURL(src) { + *externalURLs = append(*externalURLs, src) + } else { + *feishuKeys = append(*feishuKeys, src) + } + } + case "icon": + // Icon elements use icon_key + if iconKey, ok := val["icon_key"].(string); ok && iconKey != "" { + *feishuKeys = append(*feishuKeys, iconKey) + } + } + } + // Recurse into all nested structures + for _, child := range val { + extractImageKeysRecursive(child, feishuKeys, externalURLs) + } + case []any: + for _, item := range val { + extractImageKeysRecursive(item, feishuKeys, externalURLs) + } + } +} diff --git a/pkg/channels/feishu/common_test.go b/pkg/channels/feishu/common_test.go index fefc9f7c1..ff4af0148 100644 --- a/pkg/channels/feishu/common_test.go +++ b/pkg/channels/feishu/common_test.go @@ -290,3 +290,119 @@ func TestStripMentionPlaceholders(t *testing.T) { }) } } + +func TestExtractCardImageKeys(t *testing.T) { + tests := []struct { + name string + content string + wantFeishuKeys []string + wantExternalURLs []string + }{ + { + name: "empty content", + content: "", + wantFeishuKeys: nil, + wantExternalURLs: nil, + }, + { + name: "invalid JSON", + content: "not json", + wantFeishuKeys: nil, + wantExternalURLs: nil, + }, + { + name: "card with no images", + content: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"text"}]}}`, + wantFeishuKeys: nil, + wantExternalURLs: nil, + }, + { + name: "single image with img_key", + content: `{"elements":[{"tag":"img","img_key":"img_abc123"}]}`, + wantFeishuKeys: []string{"img_abc123"}, + wantExternalURLs: nil, + }, + { + name: "single image with src as Feishu key", + content: `{"elements":[{"tag":"img","src":"img_xyz789"}]}`, + wantFeishuKeys: []string{"img_xyz789"}, + wantExternalURLs: nil, + }, + { + name: "multiple images", + content: `{"elements":[{"tag":"img","img_key":"img_1"},{"tag":"div","text":{"content":"text"}},{"tag":"img","img_key":"img_2"}]}`, + wantFeishuKeys: []string{"img_1", "img_2"}, + wantExternalURLs: nil, + }, + { + name: "nested image in columns", + content: `{"elements":[{"tag":"div","columns":[{"tag":"img","img_key":"img_col1"},{"tag":"img","img_key":"img_col2"}]}]}`, + wantFeishuKeys: []string{"img_col1", "img_col2"}, + wantExternalURLs: nil, + }, + { + name: "image in action", + content: `{"elements":[{"tag":"action","actions":[{"tag":"img","img_key":"img_action"}]}]}`, + wantFeishuKeys: []string{"img_action"}, + wantExternalURLs: nil, + }, + { + name: "icon element", + content: `{"elements":[{"tag":"icon","icon_key":"icon_123"}]}`, + wantFeishuKeys: []string{"icon_123"}, + wantExternalURLs: nil, + }, + { + name: "complex card with text and images", + content: `{"header":{"title":{"content":"Title"}},"elements":[{"tag":"div","text":{"content":"Description"}},{"tag":"img","img_key":"img_main"}]}`, + wantFeishuKeys: []string{"img_main"}, + wantExternalURLs: nil, + }, + { + name: "external URL in src", + content: `{"elements":[{"tag":"img","src":"https://example.com/image.png"}]}`, + wantFeishuKeys: nil, + wantExternalURLs: []string{"https://example.com/image.png"}, + }, + { + name: "mixed Feishu keys and external URLs", + content: `{"elements":[{"tag":"img","img_key":"img_feishu"},{"tag":"img","src":"https://cdn.example.com/external.jpg"},{"tag":"img","src":"img_another"}]}`, + wantFeishuKeys: []string{"img_feishu", "img_another"}, + wantExternalURLs: []string{"https://cdn.example.com/external.jpg"}, + }, + { + name: "multiple external URLs", + content: `{"elements":[{"tag":"img","src":"https://a.com/1.png"},{"tag":"img","src":"http://b.com/2.jpg"}]}`, + wantFeishuKeys: nil, + wantExternalURLs: []string{"https://a.com/1.png", "http://b.com/2.jpg"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotFeishuKeys, gotExternalURLs := extractCardImageKeys(tt.content) + + // Compare Feishu keys + if len(gotFeishuKeys) != len(tt.wantFeishuKeys) { + t.Errorf("extractCardImageKeys() feishuKeys = %v, want %v", gotFeishuKeys, tt.wantFeishuKeys) + return + } + for i, v := range gotFeishuKeys { + if v != tt.wantFeishuKeys[i] { + t.Errorf("extractCardImageKeys() feishuKeys[%d] = %q, want %q", i, v, tt.wantFeishuKeys[i]) + } + } + + // Compare external URLs + if len(gotExternalURLs) != len(tt.wantExternalURLs) { + t.Errorf("extractCardImageKeys() externalURLs = %v, want %v", gotExternalURLs, tt.wantExternalURLs) + return + } + for i, v := range gotExternalURLs { + if v != tt.wantExternalURLs[i] { + t.Errorf("extractCardImageKeys() externalURLs[%d] = %q, want %q", i, v, tt.wantExternalURLs[i]) + } + } + }) + } +} diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 9c577d572..aaccbaab8 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "sync" "sync/atomic" @@ -129,6 +130,7 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { } // Send sends a message using Interactive Card format for markdown rendering. +// Falls back to plain text message if card sending fails (e.g., table limit exceeded). func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning @@ -141,9 +143,38 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error // Build interactive card with markdown content cardContent, err := buildMarkdownCard(msg.Content) if err != nil { - return fmt.Errorf("feishu send: card build failed: %w", err) + // If card build fails, fall back to plain text + return c.sendText(ctx, msg.ChatID, msg.Content) } - return c.sendCard(ctx, msg.ChatID, cardContent) + + // First attempt: try sending as interactive card + err = c.sendCard(ctx, msg.ChatID, cardContent) + if err == nil { + return nil + } + + // Check if error is due to card table limit (error code 11310) + // See: https://open.feishu.cn/document/server-docs/im-api/message-content-description/create_json + errMsg := err.Error() + isCardLimitError := strings.Contains(errMsg, "11310") + + if isCardLimitError { + logger.WarnCF("feishu", "Card send failed (table limit), falling back to text message", map[string]any{ + "chat_id": msg.ChatID, + "error": errMsg, + }) + + // Second attempt: fall back to plain text message + textErr := c.sendText(ctx, msg.ChatID, msg.Content) + if textErr == nil { + return nil + } + // If text also fails, return the text error + return textErr + } + + // For other errors, return the original card error + return err } // EditMessage implements channels.MessageEditor. @@ -393,6 +424,15 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. mediaRefs = c.downloadInboundMedia(ctx, chatID, messageID, messageType, rawContent, store) } + // For interactive cards, pass external image URLs via media refs. + // Keep content as valid raw JSON for downstream parsing. + if messageType == larkim.MsgTypeInteractive { + _, externalURLs := extractCardImageKeys(rawContent) + if len(externalURLs) > 0 { + mediaRefs = append(mediaRefs, externalURLs...) + } + } + // Append media tags to content (like Telegram does) content = appendMediaTags(content, messageType, mediaRefs) @@ -528,6 +568,10 @@ func extractContent(messageType, rawContent string) string { // Pass raw JSON to LLM — structured rich text is more informative than flattened plain text return rawContent + case larkim.MsgTypeInteractive: + // Pass raw JSON to LLM — structured card is more informative than flattened text + return rawContent + case larkim.MsgTypeImage: // Image messages don't have text content return "" @@ -565,6 +609,18 @@ func (c *FeishuChannel) downloadInboundMedia( refs = append(refs, ref) } + case larkim.MsgTypeInteractive: + // Extract and download images embedded in interactive cards + feishuKeys, _ := extractCardImageKeys(rawContent) + // Download Feishu-hosted images via API + for _, imageKey := range feishuKeys { + ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) + if ref != "" { + refs = append(refs, ref) + } + } + // External URLs are passed directly to LLM, not downloaded + case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia: fileKey := extractFileKey(rawContent) if fileKey == "" { @@ -685,11 +741,18 @@ func (c *FeishuChannel) downloadResource( } // appendMediaTags appends media type tags to content (like Telegram's "[image: photo]"). +// For interactive cards, media tags are not appended because content is raw JSON +// and appending would produce invalid JSON format. func appendMediaTags(content, messageType string, mediaRefs []string) string { if len(mediaRefs) == 0 { return content } + // Don't append tags to JSON content (interactive cards) - would produce invalid JSON + if messageType == larkim.MsgTypeInteractive { + return content + } + var tag string switch messageType { case larkim.MsgTypeImage: @@ -738,6 +801,35 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string return nil } +// sendText sends a plain text message to a chat (fallback when card fails). +func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error { + content, _ := json.Marshal(map[string]string{"text": text}) + + req := larkim.NewCreateMessageReqBuilder(). + ReceiveIdType(larkim.ReceiveIdTypeChatId). + Body(larkim.NewCreateMessageReqBodyBuilder(). + ReceiveId(chatID). + MsgType(larkim.MsgTypeText). + Content(string(content)). + Build()). + Build() + + resp, err := c.client.Im.V1.Message.Create(ctx, req) + if err != nil { + return fmt.Errorf("feishu send text: %w", channels.ErrTemporary) + } + + if !resp.Success() { + return fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + } + + logger.DebugCF("feishu", "Feishu text message sent (fallback)", map[string]any{ + "chat_id": chatID, + }) + + return nil +} + // sendImage uploads an image and sends it as a message. func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.File) error { // Upload image to get image_key diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index dc3eab2e7..9010abf69 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -75,6 +75,24 @@ func TestExtractContent(t *testing.T) { rawContent: "", want: "", }, + { + name: "interactive card returns raw JSON", + messageType: "interactive", + rawContent: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`, + want: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`, + }, + { + name: "interactive card with complex structure returns raw JSON", + messageType: "interactive", + rawContent: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`, + want: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`, + }, + { + name: "interactive card invalid JSON returns as-is", + messageType: "interactive", + rawContent: `not valid json`, + want: `not valid json`, + }, } for _, tt := range tests { @@ -151,6 +169,13 @@ func TestAppendMediaTags(t *testing.T) { mediaRefs: []string{"ref1"}, want: "something [attachment]", }, + { + name: "interactive card with images returns content unchanged", + content: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`, + messageType: "interactive", + mediaRefs: []string{"ref1"}, + want: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`, + }, } for _, tt := range tests { diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go index b3a493761..0cfd435b0 100644 --- a/pkg/channels/interfaces.go +++ b/pkg/channels/interfaces.go @@ -3,6 +3,7 @@ package channels import ( "context" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/commands" ) @@ -19,6 +20,11 @@ type MessageEditor interface { EditMessage(ctx context.Context, chatID string, messageID string, content string) error } +// MessageDeleter — channels that can delete a message by ID. +type MessageDeleter interface { + DeleteMessage(ctx context.Context, chatID string, messageID string) error +} + // ReactionCapable — channels that can add a reaction (e.g. 👀) to an inbound message. // ReactToMessage adds a reaction and returns an undo function to remove it. // The undo function MUST be idempotent and safe to call multiple times. @@ -35,6 +41,18 @@ type PlaceholderCapable interface { SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error) } +// StreamingCapable — channels that can show partial LLM output in real-time. +// The channel SHOULD gracefully degrade if the platform rejects streaming +// (e.g. Telegram bot without forum mode). In that case, Update becomes a no-op +// and Finalize still delivers the final message. +type StreamingCapable interface { + BeginStream(ctx context.Context, chatID string) (Streamer, error) +} + +// Streamer is defined in pkg/bus to avoid circular imports. +// This alias keeps channel implementations using channels.Streamer unchanged. +type Streamer = bus.Streamer + // PlaceholderRecorder is injected into channels by Manager. // Channels call these methods on inbound to register typing/placeholder state. // Manager uses the registered state on outbound to stop typing and edit placeholders. diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index d479ada8f..cb5c07cd4 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -89,6 +89,7 @@ type Manager struct { placeholders sync.Map // "channel:chatID" → placeholderID (string) typingStops sync.Map // "channel:chatID" → func() reactionUndos sync.Map // "channel:chatID" → reactionEntry + streamActive sync.Map // "channel:chatID" → true (set when streamer.Finalize sent the message) channelHashes map[string]string // channel name → config hash } @@ -136,6 +137,19 @@ func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) { } } +// InvokeTypingStop invokes the registered typing stop function for the given channel and chatID. +// It is safe to call even when no typing indicator is active (no-op). +// Used by the agent loop to stop typing when processing completes (success, error, or panic), +// regardless of whether an outbound message is published. +func (m *Manager) InvokeTypingStop(channel, chatID string) { + key := channel + ":" + chatID + if v, loaded := m.typingStops.LoadAndDelete(key); loaded { + if entry, ok := v.(typingEntry); ok { + entry.stop() + } + } +} + // RecordReactionUndo registers a reaction undo function for later invocation. // Implements PlaceholderRecorder. func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) { @@ -144,7 +158,7 @@ func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) { } // preSend handles typing stop, reaction undo, and placeholder editing before sending a message. -// Returns true if the message was edited into a placeholder (skip Send). +// Returns true if the message was already delivered (skip Send). func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool { key := name + ":" + msg.ChatID @@ -162,7 +176,22 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } - // 3. Try editing placeholder + // 3. If a stream already finalized this message, delete the placeholder and skip send + if _, loaded := m.streamActive.LoadAndDelete(key); loaded { + if v, loaded := m.placeholders.LoadAndDelete(key); loaded { + if entry, ok := v.(placeholderEntry); ok && entry.id != "" { + // Prefer deleting the placeholder (cleaner UX than editing to same content) + if deleter, ok := ch.(MessageDeleter); ok { + deleter.DeleteMessage(ctx, msg.ChatID, entry.id) // best effort + } else if editor, ok := ch.(MessageEditor); ok { + editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content) // fallback + } + } + } + return true + } + + // 4. Try editing placeholder if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if editor, ok := ch.(MessageEditor); ok { @@ -187,6 +216,9 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.Medi channelHashes: make(map[string]string), } + // Register as streaming delegate so the agent loop can obtain streamers + messageBus.SetStreamDelegate(m) + if err := m.initChannels(&cfg.Channels); err != nil { return nil, err } @@ -197,6 +229,53 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.Medi return m, nil } +// GetStreamer implements bus.StreamDelegate. +// It checks if the named channel supports streaming and returns a Streamer. +func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) (bus.Streamer, bool) { + m.mu.RLock() + ch, exists := m.channels[channelName] + m.mu.RUnlock() + + if !exists { + return nil, false + } + + sc, ok := ch.(StreamingCapable) + if !ok { + return nil, false + } + + streamer, err := sc.BeginStream(ctx, chatID) + if err != nil { + logger.DebugCF("channels", "Streaming unavailable, falling back to placeholder", map[string]any{ + "channel": channelName, + "error": err.Error(), + }) + return nil, false + } + + // Mark streamActive on Finalize so preSend knows to clean up the placeholder + key := channelName + ":" + chatID + return &finalizeHookStreamer{ + Streamer: streamer, + onFinalize: func() { m.streamActive.Store(key, true) }, + }, true +} + +// finalizeHookStreamer wraps a Streamer to run a hook on Finalize. +type finalizeHookStreamer struct { + Streamer + onFinalize func() +} + +func (s *finalizeHookStreamer) Finalize(ctx context.Context, content string) error { + if err := s.Streamer.Finalize(ctx, content); err != nil { + return err + } + s.onFinalize() + return nil +} + // initChannel is a helper that looks up a factory by name and creates the channel. func (m *Manager) initChannel(name, displayName string) { f, ok := getFactory(name) @@ -296,7 +375,8 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error { m.initChannel("wecom", "WeCom") } - if channels.WeComAIBot.Enabled && channels.WeComAIBot.Token() != "" { + if channels.WeComAIBot.Enabled && (channels.WeComAIBot.Token() != "" || + (channels.WeComAIBot.Secret() != "" && channels.WeComAIBot.BotID != "")) { m.initChannel("wecom_aibot", "WeCom AI Bot") } @@ -308,6 +388,10 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error { m.initChannel("pico", "Pico") } + if channels.PicoClient.Enabled && channels.PicoClient.URL != "" { + m.initChannel("pico_client", "Pico Client") + } + if channels.IRC.Enabled && channels.IRC.Server != "" { m.initChannel("irc", "IRC") } diff --git a/pkg/channels/manager_channel.go b/pkg/channels/manager_channel.go index 1ec03f010..86572e336 100644 --- a/pkg/channels/manager_channel.go +++ b/pkg/channels/manager_channel.go @@ -57,6 +57,7 @@ func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) { 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": diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index e0f55288a..7dfec9ebf 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -511,6 +511,43 @@ func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) { } } +func TestInvokeTypingStop_CallsRegisteredStop(t *testing.T) { + m := newTestManager() + var stopCalled bool + + m.RecordTypingStop("telegram", "chat123", func() { + stopCalled = true + }) + + m.InvokeTypingStop("telegram", "chat123") + + if !stopCalled { + t.Fatal("expected typing stop func to be called") + } +} + +func TestInvokeTypingStop_NoOpWhenNoEntry(t *testing.T) { + m := newTestManager() + // Should not panic + m.InvokeTypingStop("telegram", "nonexistent") +} + +func TestInvokeTypingStop_Idempotent(t *testing.T) { + m := newTestManager() + var callCount int + + m.RecordTypingStop("telegram", "chat123", func() { + callCount++ + }) + + m.InvokeTypingStop("telegram", "chat123") + m.InvokeTypingStop("telegram", "chat123") // Second call: entry already removed, no-op + + if callCount != 1 { + t.Fatalf("expected stop to be called once, got %d", callCount) + } +} + func TestPreSend_TypingStopCalled(t *testing.T) { m := newTestManager() var stopCalled bool diff --git a/pkg/channels/pico/client.go b/pkg/channels/pico/client.go new file mode 100644 index 000000000..2c335050d --- /dev/null +++ b/pkg/channels/pico/client.go @@ -0,0 +1,319 @@ +package pico + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// PicoClientChannel connects to a remote Pico Protocol WebSocket server. +type PicoClientChannel struct { + *channels.BaseChannel + config config.PicoClientConfig + conn *picoConn + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc +} + +// NewPicoClientChannel creates a new Pico Protocol client channel. +func NewPicoClientChannel( + cfg config.PicoClientConfig, + messageBus *bus.MessageBus, +) (*PicoClientChannel, error) { + if cfg.URL == "" { + return nil, fmt.Errorf("pico_client url is required") + } + + base := channels.NewBaseChannel("pico_client", cfg, messageBus, cfg.AllowFrom) + + return &PicoClientChannel{ + BaseChannel: base, + config: cfg, + }, nil +} + +// Start dials the remote server and begins reading. +func (c *PicoClientChannel) Start(ctx context.Context) error { + logger.InfoC("pico_client", "Starting Pico Client channel") + c.ctx, c.cancel = context.WithCancel(ctx) + + if err := c.dial(); err != nil { + c.cancel() + return fmt.Errorf("pico_client initial connect: %w", err) + } + + c.SetRunning(true) + go c.reconnectLoop() + + logger.InfoCF("pico_client", "Connected", map[string]any{"url": c.config.URL}) + return nil +} + +// Stop closes the connection. +func (c *PicoClientChannel) Stop(ctx context.Context) error { + logger.InfoC("pico_client", "Stopping Pico Client channel") + c.SetRunning(false) + if c.cancel != nil { + c.cancel() + } + c.mu.Lock() + if c.conn != nil { + c.conn.close() + } + c.mu.Unlock() + logger.InfoC("pico_client", "Pico Client channel stopped") + return nil +} + +func (c *PicoClientChannel) dial() error { + header := http.Header{} + if c.config.Token != "" { + header.Set("Authorization", "Bearer "+c.config.Token) + } + + ws, resp, err := websocket.DefaultDialer.DialContext(c.ctx, c.config.URL, header) + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + if err != nil { + return err + } + + connCtx, connCancel := context.WithCancel(c.ctx) + + pc := &picoConn{ + id: uuid.New().String(), + conn: ws, + sessionID: c.config.SessionID, + cancel: connCancel, + } + if pc.sessionID == "" { + pc.sessionID = uuid.New().String() + } + + c.mu.Lock() + c.conn = pc + c.mu.Unlock() + + go c.readLoop(connCtx, pc) + return nil +} + +// reconnectLoop re-dials when the connection drops. +func (c *PicoClientChannel) reconnectLoop() { + for { + select { + case <-c.ctx.Done(): + return + default: + } + + c.mu.Lock() + pc := c.conn + c.mu.Unlock() + + if pc == nil || pc.closed.Load() { + backoff := 5 * time.Second + logger.InfoC("pico_client", "Reconnecting...") + if err := c.dial(); err != nil { + logger.WarnCF("pico_client", "Reconnect failed", map[string]any{ + "error": err.Error(), + }) + select { + case <-c.ctx.Done(): + return + case <-time.After(backoff): + } + continue + } + logger.InfoC("pico_client", "Reconnected") + } + + select { + case <-c.ctx.Done(): + return + case <-time.After(1 * time.Second): + } + } +} + +func (c *PicoClientChannel) readLoop(connCtx context.Context, pc *picoConn) { + defer pc.close() + + readTimeout := time.Duration(c.config.ReadTimeout) * time.Second + if readTimeout <= 0 { + readTimeout = 60 * time.Second + } + + _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout)) + pc.conn.SetPongHandler(func(string) error { + return pc.conn.SetReadDeadline(time.Now().Add(readTimeout)) + }) + + pingInterval := time.Duration(c.config.PingInterval) * time.Second + if pingInterval <= 0 { + pingInterval = 30 * time.Second + } + go c.pingLoop(connCtx, pc, pingInterval) + + for { + select { + case <-connCtx.Done(): + return + default: + } + + _, raw, err := pc.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError( + err, + websocket.CloseGoingAway, + websocket.CloseNormalClosure, + ) { + logger.DebugCF("pico_client", "Read error", map[string]any{ + "error": err.Error(), + }) + } + return + } + + _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout)) + + var msg PicoMessage + if err := json.Unmarshal(raw, &msg); err != nil { + continue + } + + c.handleInbound(pc, msg) + } +} + +func (c *PicoClientChannel) pingLoop(connCtx context.Context, pc *picoConn, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-connCtx.Done(): + return + case <-ticker.C: + if pc.closed.Load() { + return + } + pc.writeMu.Lock() + err := pc.conn.WriteMessage(websocket.PingMessage, nil) + pc.writeMu.Unlock() + if err != nil { + return + } + } + } +} + +// handleInbound processes messages from the remote server. +// In client mode the server sends message.create (responses) and the client +// sends message.send (user input). We treat message.create from the server +// as inbound user messages to feed into the agent loop. +func (c *PicoClientChannel) handleInbound(pc *picoConn, msg PicoMessage) { + switch msg.Type { + case TypePong: + // response to our ping, ignore + case TypeMessageCreate: + // Server sent us a message — treat as inbound + c.handleServerMessage(pc, msg) + default: + logger.DebugCF("pico_client", "Ignoring message type", map[string]any{ + "type": msg.Type, + }) + } +} + +func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) { + content, _ := msg.Payload["content"].(string) + if strings.TrimSpace(content) == "" { + return + } + + sessionID := msg.SessionID + if sessionID == "" { + sessionID = pc.sessionID + } + + chatID := "pico_client:" + sessionID + senderID := "pico-remote" + peer := bus.Peer{Kind: "direct", ID: chatID} + + sender := bus.SenderInfo{ + Platform: "pico_client", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("pico_client", senderID), + } + + if !c.IsAllowedSender(sender) { + return + } + + c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, map[string]string{ + "platform": "pico_client", + "session_id": sessionID, + }, sender) +} + +// Send sends a message to the remote server. +func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + c.mu.Lock() + pc := c.conn + c.mu.Unlock() + if pc == nil || pc.closed.Load() { + return channels.ErrSendFailed + } + + outMsg := newMessage(TypeMessageSend, map[string]any{ + "content": msg.Content, + }) + outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:") + return pc.writeJSON(outMsg) +} + +// StartTyping implements channels.TypingCapable. +func (c *PicoClientChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + c.mu.Lock() + pc := c.conn + c.mu.Unlock() + if pc == nil || pc.closed.Load() { + return func() {}, nil + } + + startMsg := newMessage(TypeTypingStart, nil) + startMsg.SessionID = strings.TrimPrefix(chatID, "pico_client:") + if err := pc.writeJSON(startMsg); err != nil { + return func() {}, err + } + return func() { + c.mu.Lock() + currentPC := c.conn + c.mu.Unlock() + if currentPC == nil { + return + } + stopMsg := newMessage(TypeTypingStop, nil) + stopMsg.SessionID = strings.TrimPrefix(chatID, "pico_client:") + currentPC.writeJSON(stopMsg) + }, nil +} diff --git a/pkg/channels/pico/client_test.go b/pkg/channels/pico/client_test.go new file mode 100644 index 000000000..118c9abea --- /dev/null +++ b/pkg/channels/pico/client_test.go @@ -0,0 +1,264 @@ +package pico + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestNewPicoClientChannel_MissingURL(t *testing.T) { + _, err := NewPicoClientChannel(config.PicoClientConfig{}, bus.NewMessageBus()) + if err == nil { + t.Fatal("expected error for missing URL") + } + if !strings.Contains(err.Error(), "url is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNewPicoClientChannel_OK(t *testing.T) { + ch, err := NewPicoClientChannel(config.PicoClientConfig{ + URL: "ws://localhost:9999/ws", + }, bus.NewMessageBus()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ch.Name() != "pico_client" { + t.Fatalf("name = %q, want pico_client", ch.Name()) + } +} + +func TestSend_NotRunning(t *testing.T) { + ch, err := NewPicoClientChannel(config.PicoClientConfig{ + URL: "ws://localhost:9999/ws", + }, bus.NewMessageBus()) + if err != nil { + t.Fatal(err) + } + err = ch.Send(context.Background(), bus.OutboundMessage{Content: "hi"}) + if !errors.Is(err, channels.ErrNotRunning) { + t.Fatalf("expected ErrNotRunning, got %v", err) + } +} + +// testServer starts a WS server that echoes message.send back as message.create. +func testServer(t *testing.T, token string) *httptest.Server { + t.Helper() + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if token != "" { + auth := r.Header.Get("Authorization") + if auth != "Bearer "+token { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Logf("upgrade error: %v", err) + return + } + defer conn.Close() + + for { + _, raw, err := conn.ReadMessage() + if err != nil { + return + } + + var msg PicoMessage + if err := json.Unmarshal(raw, &msg); err != nil { + continue + } + + if msg.Type == TypeMessageSend { + reply := newMessage(TypeMessageCreate, msg.Payload) + reply.SessionID = msg.SessionID + if err := conn.WriteJSON(reply); err != nil { + return + } + } + } + })) +} + +func wsURL(httpURL string) string { + return "ws" + strings.TrimPrefix(httpURL, "http") +} + +func TestClientChannel_ConnectAndSend(t *testing.T) { + srv := testServer(t, "test-token") + defer srv.Close() + + mb := bus.NewMessageBus() + ch, err := NewPicoClientChannel(config.PicoClientConfig{ + URL: wsURL(srv.URL), + Token: "test-token", + SessionID: "sess-1", + PingInterval: 60, + ReadTimeout: 10, + }, mb) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err = ch.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + defer ch.Stop(ctx) + + // Send a message + err = ch.Send(ctx, bus.OutboundMessage{ + ChatID: "pico_client:sess-1", + Content: "hello", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } +} + +func TestClientChannel_AuthFailure(t *testing.T) { + srv := testServer(t, "correct-token") + defer srv.Close() + + ch, err := NewPicoClientChannel(config.PicoClientConfig{ + URL: wsURL(srv.URL), + Token: "wrong-token", + }, bus.NewMessageBus()) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err = ch.Start(ctx) + if err == nil { + ch.Stop(ctx) + t.Fatal("expected auth failure") + } +} + +func TestClientChannel_ReceivesServerMessage(t *testing.T) { + srv := testServer(t, "") + defer srv.Close() + + mb := bus.NewMessageBus() + + ch, err := NewPicoClientChannel(config.PicoClientConfig{ + URL: wsURL(srv.URL), + SessionID: "sess-echo", + ReadTimeout: 10, + }, mb) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err = ch.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + defer ch.Stop(ctx) + + // Send a message; the echo server replies with message.create + err = ch.Send(ctx, bus.OutboundMessage{ + ChatID: "pico_client:sess-echo", + Content: "ping", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + + // The echoed message.create is processed by handleServerMessage which + // calls HandleMessage → PublishInbound. Consume it from the bus. + select { + case msg := <-mb.InboundChan(): + if msg.Content != "ping" { + t.Fatalf("received = %q, want %q", msg.Content, "ping") + } + case <-ctx.Done(): + t.Fatal("timed out waiting for echoed message") + } +} + +func TestClientChannel_StartTyping(t *testing.T) { + srv := testServer(t, "") + defer srv.Close() + + ch, err := NewPicoClientChannel(config.PicoClientConfig{ + URL: wsURL(srv.URL), + SessionID: "sess-type", + ReadTimeout: 10, + }, bus.NewMessageBus()) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err = ch.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + defer ch.Stop(ctx) + + stop, err := ch.StartTyping(ctx, "pico_client:sess-type") + if err != nil { + t.Fatalf("StartTyping: %v", err) + } + stop() // should not panic +} + +func TestSend_ClosedConnection(t *testing.T) { + srv := testServer(t, "") + defer srv.Close() + + ch, err := NewPicoClientChannel(config.PicoClientConfig{ + URL: wsURL(srv.URL), + SessionID: "sess-close", + ReadTimeout: 10, + }, bus.NewMessageBus()) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err = ch.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + + // Force close the underlying connection + ch.mu.Lock() + ch.conn.close() + ch.mu.Unlock() + + err = ch.Send(ctx, bus.OutboundMessage{ + ChatID: "pico_client:sess-close", + Content: "should fail", + }) + if !errors.Is(err, channels.ErrSendFailed) { + t.Fatalf("expected ErrSendFailed, got %v", err) + } + + ch.Stop(ctx) +} diff --git a/pkg/channels/pico/init.go b/pkg/channels/pico/init.go index 96d764418..0319279d8 100644 --- a/pkg/channels/pico/init.go +++ b/pkg/channels/pico/init.go @@ -10,4 +10,7 @@ func init() { channels.RegisterFactory("pico", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewPicoChannel(cfg.Channels.Pico, b) }) + channels.RegisterFactory("pico_client", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewPicoClientChannel(cfg.Channels.PicoClient, b) + }) } diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index e5d092d2c..86ce98b06 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -27,6 +27,7 @@ type picoConn struct { sessionID string writeMu sync.Mutex closed atomic.Bool + cancel context.CancelFunc // cancels per-connection goroutines (e.g. pingLoop) } // writeJSON sends a JSON message to the connection with write locking. @@ -42,6 +43,9 @@ func (pc *picoConn) writeJSON(v any) error { // close closes the connection. func (pc *picoConn) close() { if pc.closed.CompareAndSwap(false, true) { + if pc.cancel != nil { + pc.cancel() + } pc.conn.Close() } } diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 63cfd1915..50b1e23aa 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -2,6 +2,8 @@ package telegram import ( "context" + "crypto/rand" + "encoding/binary" "fmt" "io" "net/http" @@ -10,6 +12,7 @@ import ( "regexp" "strconv" "strings" + "sync" "time" "github.com/mymmrac/telego" @@ -302,10 +305,17 @@ func (c *TelegramChannel) sendChunk( return nil } +// maxTypingDuration limits how long the typing indicator can run. +// Prevents endless typing when the LLM fails/hangs and preSend never invokes cancel. +// Matches channels.Manager's typingStopTTL (5 min) so behavior is consistent. +const maxTypingDuration = 5 * time.Minute + // StartTyping implements channels.TypingCapable. // It sends ChatAction(typing) immediately and then repeats every 4 seconds // (Telegram's typing indicator expires after ~5s) in a background goroutine. // The returned stop function is idempotent and cancels the goroutine. +// The goroutine also exits automatically after maxTypingDuration if cancel is +// never called (e.g. when the LLM fails or times out without publishing). func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { cid, threadID, err := parseTelegramChatID(chatID) if err != nil { @@ -319,12 +329,15 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func( _ = c.bot.SendChatAction(ctx, action) typingCtx, cancel := context.WithCancel(ctx) + // Cap lifetime so the goroutine cannot run indefinitely if cancel is never called + maxCtx, maxCancel := context.WithTimeout(typingCtx, maxTypingDuration) go func() { + defer maxCancel() ticker := time.NewTicker(4 * time.Second) defer ticker.Stop() for { select { - case <-typingCtx.Done(): + case <-maxCtx.Done(): return case <-ticker.C: a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping) @@ -364,6 +377,22 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag return err } +// DeleteMessage implements channels.MessageDeleter. +func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { + cid, _, err := parseTelegramChatID(chatID) + if err != nil { + return err + } + mid, err := strconv.Atoi(messageID) + if err != nil { + return err + } + return c.bot.DeleteMessage(ctx, &telego.DeleteMessageParams{ + ChatID: tu.ID(cid), + MessageID: mid, + }) +} + // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message (e.g. "Thinking... 💭") that will later be // edited to the actual response via EditMessage (channels.MessageEditor). @@ -837,3 +866,107 @@ func (c *TelegramChannel) stripBotMention(content string) string { content = re.ReplaceAllString(content, "") return strings.TrimSpace(content) } + +// BeginStream implements channels.StreamingCapable. +func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (channels.Streamer, error) { + if !c.config.Channels.Telegram.Streaming.Enabled { + return nil, fmt.Errorf("streaming disabled in config") + } + + cid, _, err := parseTelegramChatID(chatID) + if err != nil { + return nil, err + } + + streamCfg := c.config.Channels.Telegram.Streaming + return &telegramStreamer{ + bot: c.bot, + chatID: cid, + draftID: cryptoRandInt(), + throttleInterval: time.Duration(streamCfg.ThrottleSeconds) * time.Second, + minGrowth: streamCfg.MinGrowthChars, + }, nil +} + +// telegramStreamer streams partial LLM output via Telegram's sendMessageDraft API. +// On first API error (e.g. bot lacks forum mode), it silently degrades: Update +// becomes a no-op, while Finalize still delivers the final message. +type telegramStreamer struct { + bot *telego.Bot + chatID int64 + draftID int + throttleInterval time.Duration + minGrowth int + lastLen int + lastAt time.Time + failed bool + mu sync.Mutex +} + +func (s *telegramStreamer) Update(ctx context.Context, content string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.failed { + return nil + } + + // Throttle: skip if not enough time or content has passed + now := time.Now() + growth := len(content) - s.lastLen + if s.lastLen > 0 && now.Sub(s.lastAt) < s.throttleInterval && growth < s.minGrowth { + return nil + } + + htmlContent := markdownToTelegramHTML(content) + + err := s.bot.SendMessageDraft(ctx, &telego.SendMessageDraftParams{ + ChatID: s.chatID, + DraftID: s.draftID, + Text: htmlContent, + ParseMode: telego.ModeHTML, + }) + if err != nil { + // First error → degrade silently (e.g. no forum mode) + logger.WarnCF("telegram", "sendMessageDraft failed, disabling streaming", map[string]any{ + "error": err.Error(), + }) + s.failed = true + return nil // don't propagate — Finalize will still deliver + } + + s.lastLen = len(content) + s.lastAt = now + return nil +} + +func (s *telegramStreamer) Finalize(ctx context.Context, content string) error { + htmlContent := markdownToTelegramHTML(content) + tgMsg := tu.Message(tu.ID(s.chatID), htmlContent) + tgMsg.ParseMode = telego.ModeHTML + + if _, err := s.bot.SendMessage(ctx, tgMsg); err != nil { + // Fallback to plain text + tgMsg.ParseMode = "" + if _, err = s.bot.SendMessage(ctx, tgMsg); err != nil { + logger.ErrorCF("telegram", "Finalize failed after HTML and plain-text attempts", map[string]any{ + "chat_id": s.chatID, + "error": err.Error(), + "len": len(content), + }) + return fmt.Errorf("telegram finalize: %w", err) + } + } + return nil +} + +func (s *telegramStreamer) Cancel(ctx context.Context) { + // Draft auto-expires on Telegram's side; nothing to clean up. +} + +// cryptoRandInt returns a non-zero random int using crypto/rand. +func cryptoRandInt() int { + var b [4]byte + _, _ = rand.Read(b[:]) + return int(binary.BigEndian.Uint32(b[:])) | 1 // ensure non-zero +} diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 87ab61452..c5e148185 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -22,6 +22,10 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) +// responseURLHTTPClient is a shared HTTP client for posting to WeCom response_url. +// Reusing it enables connection pooling across replies. +var responseURLHTTPClient = &http.Client{Timeout: 15 * time.Second} + // WeComAIBotChannel implements the Channel interface for WeCom AI Bot (企业微信智能机器人) type WeComAIBotChannel struct { *channels.BaseChannel @@ -134,13 +138,28 @@ type WeComAIBotEncryptedResponse struct { Nonce string `json:"nonce"` } -// NewWeComAIBotChannel creates a new WeCom AI Bot channel instance +// NewWeComAIBotChannel creates a WeCom AI Bot channel instance. +// If cfg.BotID and cfg.secret are both set, it returns a WeComAIBotWSChannel +// using the WebSocket long-connection API. +// Otherwise it returns the webhook-mode WeComAIBotChannel (requires Token + +// EncodingAESKey). func NewWeComAIBotChannel( cfg config.WeComAIBotConfig, messageBus *bus.MessageBus, -) (*WeComAIBotChannel, error) { +) (channels.Channel, error) { + // WebSocket long-connection mode takes priority when BotID + secret are set. + if cfg.BotID != "" && cfg.Secret() != "" { + logger.InfoC("wecom_aibot", "BotID and secret provided, using WebSocket mode") + return newWeComAIBotWSChannel(cfg, messageBus) + } + // Webhook (short-connection) mode. if cfg.Token() == "" || cfg.EncodingAESKey() == "" { - return nil, fmt.Errorf("token and encoding_aes_key are required for WeCom AI Bot") + return nil, fmt.Errorf( + "WeCom AI Bot requires either (bot_id + secret) for WebSocket mode " + + "or (token + encoding_aes_key) for webhook mode") + } + if cfg.ProcessingMessage == "" { + cfg.ProcessingMessage = config.DefaultWeComAIBotProcessingMessage } base := channels.NewBaseChannel("wecom_aibot", cfg, messageBus, cfg.AllowFrom, @@ -693,7 +712,7 @@ func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce default: if time.Now().After(task.Deadline) { // Deadline reached: close the stream with a notice, then wait for agent via response_url. - content = "⏳ Processing, please wait. The results will be sent shortly." + content = c.config.ProcessingMessage finish = true closeStreamOnly = true logger.InfoCF( @@ -782,8 +801,7 @@ func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) erro } req.Header.Set("Content-Type", "application/json; charset=utf-8") - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) + resp, err := responseURLHTTPClient.Do(req) if err != nil { return fmt.Errorf("post to response_url failed: %w: %w", channels.ErrTemporary, err) } @@ -793,7 +811,8 @@ func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) erro return nil } - respBody, err := io.ReadAll(resp.Body) + const maxErrBody = 64 << 10 // 64 KB is more than enough for any error response + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxErrBody)) if err != nil { return fmt.Errorf("reading response_url body: %w: %w", channels.ErrTemporary, err) } @@ -895,17 +914,80 @@ func (c *WeComAIBotChannel) encryptMessage(plaintext, receiveid string) (string, return base64.StdEncoding.EncodeToString(ciphertext), nil } -// generateStreamID generates a random stream ID -func (c *WeComAIBotChannel) generateStreamID() string { +// func (c *WeComAIBotChannel) downloadAndDecryptImage( +// ctx context.Context, +// imageURL string, +// ) ([]byte, error) { +// // Download image +// req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) +// if err != nil { +// return nil, fmt.Errorf("failed to create request: %w", err) +// } + +// client := &http.Client{ +// Timeout: 15 * time.Second, +// } + +// resp, err := client.Do(req) +// if err != nil { +// return nil, fmt.Errorf("failed to download image: %w", err) +// } +// defer resp.Body.Close() + +// if resp.StatusCode != http.StatusOK { +// return nil, fmt.Errorf("download failed with status: %d", resp.StatusCode) +// } + +// // Limit image download to 20 MB to prevent memory exhaustion +// const maxImageSize = 20 << 20 // 20 MB +// encryptedData, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize+1)) +// if err != nil { +// return nil, fmt.Errorf("failed to read image data: %w", err) +// } +// if len(encryptedData) > maxImageSize { +// return nil, fmt.Errorf("image too large (exceeds %d MB)", maxImageSize>>20) +// } + +// logger.DebugCF("wecom_aibot", "Image downloaded", map[string]any{ +// "size": len(encryptedData), +// }) + +// // Decode AES key +// aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey) +// if err != nil { +// return nil, err +// } + +// // Decrypt image (AES-CBC with IV = first 16 bytes of key, PKCS7 padding stripped) +// decryptedData, err := decryptAESCBC(aesKey, encryptedData) +// if err != nil { +// return nil, fmt.Errorf("failed to decrypt image: %w", err) +// } + +// logger.DebugCF("wecom_aibot", "Image decrypted", map[string]any{ +// "size": len(decryptedData), +// }) + +// return decryptedData, nil +// } + +// generateRandomID generates a cryptographically random alphanumeric ID of +// length n. Used for stream IDs and WebSocket request IDs. +func generateRandomID(n int) string { const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - b := make([]byte, 10) + b := make([]byte, n) for i := range b { - n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) - b[i] = letters[n.Int64()] + num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) + b[i] = letters[num.Int64()] } return string(b) } +// generateStreamID generates a random 10-character stream ID (webhook mode). +func (c *WeComAIBotChannel) generateStreamID() string { + return generateRandomID(10) +} + // cleanupLoop periodically cleans up old streaming tasks func (c *WeComAIBotChannel) cleanupLoop() { ticker := time.NewTicker(5 * time.Minute) diff --git a/pkg/channels/wecom/aibot_test.go b/pkg/channels/wecom/aibot_test.go index 315dbec21..11c4393d6 100644 --- a/pkg/channels/wecom/aibot_test.go +++ b/pkg/channels/wecom/aibot_test.go @@ -2,13 +2,18 @@ package wecom import ( "context" + "encoding/json" "testing" + "time" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) -func TestNewWeComAIBotChannel(t *testing.T) { +// ---- Webhook mode tests ---- + +func TestNewWeComAIBotChannel_WebhookMode(t *testing.T) { t.Run("success with valid config", func(t *testing.T) { cfg := config.WeComAIBotConfig{} cfg.Enabled = true @@ -21,14 +26,16 @@ func TestNewWeComAIBotChannel(t *testing.T) { if err != nil { t.Fatalf("Expected no error, got %v", err) } - if ch == nil { t.Fatal("Expected channel to be created") } - if ch.Name() != "wecom_aibot" { t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name()) } + // Webhook mode must implement WebhookHandler. + if _, ok := ch.(channels.WebhookHandler); !ok { + t.Error("Webhook mode channel should implement WebhookHandler") + } }) t.Run("error with missing token", func(t *testing.T) { @@ -38,7 +45,6 @@ func TestNewWeComAIBotChannel(t *testing.T) { messageBus := bus.NewMessageBus() _, err := NewWeComAIBotChannel(cfg, messageBus) - if err == nil { t.Fatal("Expected error for missing token, got nil") } @@ -51,16 +57,16 @@ func TestNewWeComAIBotChannel(t *testing.T) { messageBus := bus.NewMessageBus() _, err := NewWeComAIBotChannel(cfg, messageBus) - if err == nil { t.Fatal("Expected error for missing encoding key, got nil") } }) } -func TestWeComAIBotChannelStartStop(t *testing.T) { - cfg := config.WeComAIBotConfig{} - cfg.Enabled = true +func TestWeComAIBotWebhookChannelStartStop(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + } cfg.SetToken("test_token") cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567") @@ -72,22 +78,18 @@ func TestWeComAIBotChannelStartStop(t *testing.T) { ctx := context.Background() - // Test Start if err := ch.Start(ctx); err != nil { t.Fatalf("Failed to start channel: %v", err) } - if !ch.IsRunning() { - t.Error("Expected channel to be running") + t.Error("Expected channel to be running after Start") } - // Test Stop if err := ch.Stop(ctx); err != nil { t.Fatalf("Failed to stop channel: %v", err) } - if ch.IsRunning() { - t.Error("Expected channel to be stopped") + t.Error("Expected channel to be stopped after Stop") } } @@ -101,9 +103,13 @@ func TestWeComAIBotChannelWebhookPath(t *testing.T) { messageBus := bus.NewMessageBus() ch, _ := NewWeComAIBotChannel(cfg, messageBus) + wh, ok := ch.(channels.WebhookHandler) + if !ok { + t.Fatal("Expected channel to implement WebhookHandler") + } expectedPath := "/webhook/wecom-aibot" - if ch.WebhookPath() != expectedPath { - t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, ch.WebhookPath()) + if wh.WebhookPath() != expectedPath { + t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, wh.WebhookPath()) } }) @@ -118,8 +124,93 @@ func TestWeComAIBotChannelWebhookPath(t *testing.T) { messageBus := bus.NewMessageBus() ch, _ := NewWeComAIBotChannel(cfg, messageBus) - if ch.WebhookPath() != customPath { - t.Errorf("Expected webhook path '%s', got '%s'", customPath, ch.WebhookPath()) + wh, ok := ch.(channels.WebhookHandler) + if !ok { + t.Fatal("Expected channel to implement WebhookHandler") + } + if wh.WebhookPath() != customPath { + t.Errorf("Expected webhook path '%s', got '%s'", customPath, wh.WebhookPath()) + } + }) +} + +func TestWeComAIBotChannelGetStreamResponseProcessingMessage(t *testing.T) { + validAESKey := "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG" + + t.Run("uses default processing message", func(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + } + cfg.SetToken("test_token") + cfg.SetEncodingAESKey(validAESKey) + + messageBus := bus.NewMessageBus() + channel, err := NewWeComAIBotChannel(cfg, messageBus) + if err != nil { + t.Fatalf("Failed to create channel: %v", err) + } + ch, ok := channel.(*WeComAIBotChannel) + if !ok { + t.Fatal("Expected webhook mode channel") + } + + task := &streamTask{ + StreamID: "stream-default", + ChatID: "chat-default", + Deadline: time.Now().Add(-time.Second), + } + ch.streamTasks[task.StreamID] = task + ch.chatTasks[task.ChatID] = []*streamTask{task} + + resp := decodeStreamResponse(t, ch, ch.getStreamResponse(task, "1234567890", "nonce")) + + if !resp.Stream.Finish { + t.Fatal("Expected finished stream response after deadline") + } + if resp.Stream.Content != config.DefaultWeComAIBotProcessingMessage { + t.Fatalf("Expected default processing message %q, got %q", + config.DefaultWeComAIBotProcessingMessage, resp.Stream.Content) + } + if !task.StreamClosed { + t.Fatal("Expected task stream to be marked closed") + } + if _, ok := ch.streamTasks[task.StreamID]; ok { + t.Fatal("Expected closed stream task to be removed from streamTasks") + } + if len(ch.chatTasks[task.ChatID]) != 1 { + t.Fatalf("Expected task to remain queued for response_url delivery, got %d entries", + len(ch.chatTasks[task.ChatID])) + } + }) + + t.Run("uses custom processing message", func(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + 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) + if err != nil { + t.Fatalf("Failed to create channel: %v", err) + } + ch, ok := channel.(*WeComAIBotChannel) + if !ok { + t.Fatal("Expected webhook mode channel") + } + + task := &streamTask{ + StreamID: "stream-custom", + ChatID: "chat-custom", + Deadline: time.Now().Add(-time.Second), + } + + resp := decodeStreamResponse(t, ch, ch.getStreamResponse(task, "1234567890", "nonce")) + + if resp.Stream.Content != cfg.ProcessingMessage { + t.Fatalf("Expected custom processing message %q, got %q", cfg.ProcessingMessage, resp.Stream.Content) } }) } @@ -132,16 +223,17 @@ func TestGenerateStreamID(t *testing.T) { messageBus := bus.NewMessageBus() ch, _ := NewWeComAIBotChannel(cfg, messageBus) + webhookCh, ok := ch.(*WeComAIBotChannel) + if !ok { + t.Fatal("Expected webhook mode channel") + } - // Generate multiple IDs and check they are unique ids := make(map[string]bool) for i := 0; i < 100; i++ { - id := ch.generateStreamID() - + id := webhookCh.generateStreamID() if len(id) != 10 { t.Errorf("Expected stream ID length 10, got %d", len(id)) } - if ids[id] { t.Errorf("Duplicate stream ID generated: %s", id) } @@ -158,16 +250,18 @@ func TestEncryptDecrypt(t *testing.T) { messageBus := bus.NewMessageBus() ch, _ := NewWeComAIBotChannel(cfg, messageBus) + webhookCh, ok := ch.(*WeComAIBotChannel) + if !ok { + t.Fatal("Expected webhook mode channel") + } plaintext := "Hello, World!" receiveid := "" - // Encrypt - encrypted, err := ch.encryptMessage(plaintext, receiveid) + encrypted, err := webhookCh.encryptMessage(plaintext, receiveid) if err != nil { t.Fatalf("Failed to encrypt message: %v", err) } - if encrypted == "" { t.Fatal("Encrypted message is empty") } @@ -177,7 +271,6 @@ func TestEncryptDecrypt(t *testing.T) { if err != nil { t.Fatalf("Failed to decrypt message: %v", err) } - if decrypted != plaintext { t.Errorf("Expected decrypted message '%s', got '%s'", plaintext, decrypted) } @@ -190,13 +283,277 @@ func TestGenerateSignature(t *testing.T) { encrypt := "encrypted_msg" signature := computeSignature(token, timestamp, nonce, encrypt) - if signature == "" { t.Error("Generated signature is empty") } - - // Verify signature using verifySignature function if !verifySignature(token, signature, timestamp, nonce, encrypt) { t.Error("Generated signature does not verify correctly") } } + +func decodeStreamResponse(t *testing.T, ch *WeComAIBotChannel, encryptedResponse string) WeComAIBotStreamResponse { + t.Helper() + + var wrapped WeComAIBotEncryptedResponse + if err := json.Unmarshal([]byte(encryptedResponse), &wrapped); err != nil { + t.Fatalf("Failed to unmarshal encrypted response: %v", err) + } + + plaintext, err := decryptMessageWithVerify(wrapped.Encrypt, ch.config.EncodingAESKey(), "") + if err != nil { + t.Fatalf("Failed to decrypt response: %v", err) + } + + var resp WeComAIBotStreamResponse + if err := json.Unmarshal([]byte(plaintext), &resp); err != nil { + t.Fatalf("Failed to unmarshal decrypted response: %v", err) + } + + return resp +} + +// ---- WebSocket long-connection mode tests ---- + +func TestNewWeComAIBotChannel_WSMode(t *testing.T) { + t.Run("success with bot_id and secret", func(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + BotID: "test_bot_id", + } + cfg.SetSecret("test_secret") + messageBus := bus.NewMessageBus() + ch, err := NewWeComAIBotChannel(cfg, messageBus) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if ch == nil { + t.Fatal("Expected channel to be created") + } + if ch.Name() != "wecom_aibot" { + t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name()) + } + // WebSocket mode must NOT implement WebhookHandler. + if _, ok := ch.(channels.WebhookHandler); ok { + t.Error("WebSocket mode channel should NOT implement WebhookHandler") + } + }) + + t.Run("ws mode takes priority over webhook fields", func(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + BotID: "test_bot_id", + } + 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.Run("error with missing bot_id", func(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + } + 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. + if err == nil { + t.Fatal("Expected error for missing bot_id, got nil") + } + }) + + t.Run("error with missing secret", func(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + BotID: "test_bot_id", + } + messageBus := bus.NewMessageBus() + _, err := NewWeComAIBotChannel(cfg, messageBus) + if err == nil { + t.Fatal("Expected error for missing secret, got nil") + } + }) +} + +func TestWeComAIBotWSChannelStartStop(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + BotID: "test_bot_id", + } + cfg.SetSecret("test_secret") + messageBus := bus.NewMessageBus() + ch, err := NewWeComAIBotChannel(cfg, messageBus) + if err != nil { + t.Fatalf("Failed to create channel: %v", err) + } + + ctx := context.Background() + + // Start launches a background goroutine; it should not block or return an error. + if err := ch.Start(ctx); err != nil { + t.Fatalf("Failed to start channel: %v", err) + } + if !ch.IsRunning() { + t.Error("Expected channel to be running after Start") + } + + // Stop should work regardless of whether the WebSocket actually connected. + if err := ch.Stop(ctx); err != nil { + t.Fatalf("Failed to stop channel: %v", err) + } + if ch.IsRunning() { + t.Error("Expected channel to be stopped after Stop") + } +} + +func TestGenerateRandomID(t *testing.T) { + ids := make(map[string]bool) + for i := 0; i < 200; i++ { + id := generateRandomID(10) + if len(id) != 10 { + t.Errorf("Expected ID length 10, got %d", len(id)) + } + if ids[id] { + t.Errorf("Duplicate ID generated: %s", id) + } + ids[id] = true + } +} + +func TestWSGenerateID(t *testing.T) { + ids := make(map[string]bool) + for i := 0; i < 200; i++ { + id := wsGenerateID() + if len(id) != 10 { + t.Errorf("Expected ID length 10, got %d", len(id)) + } + if ids[id] { + t.Errorf("Duplicate wsGenerateID result: %s", id) + } + ids[id] = true + } +} + +// ---- Webhook streaming fallback tests ---- + +// makeWebhookChannel creates a started WeComAIBotChannel for testing. +func makeWebhookChannel(t *testing.T) *WeComAIBotChannel { + t.Helper() + cfg := config.WeComAIBotConfig{ + Enabled: true, + } + cfg.SetToken("test_token") + cfg.SetEncodingAESKey("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG") + ch, err := NewWeComAIBotChannel(cfg, bus.NewMessageBus()) + if err != nil { + t.Fatalf("create channel: %v", err) + } + wc := ch.(*WeComAIBotChannel) + wc.ctx, wc.cancel = context.WithCancel(context.Background()) + return wc +} + +// makeStreamTask creates and registers a streamTask for testing. +func makeStreamTask(t *testing.T, ch *WeComAIBotChannel, streamID, chatID string, deadline time.Time) *streamTask { + t.Helper() + task := &streamTask{ + StreamID: streamID, + ChatID: chatID, + Deadline: deadline, + answerCh: make(chan string, 1), + } + task.ctx, task.cancel = context.WithCancel(ch.ctx) + ch.taskMu.Lock() + ch.streamTasks[streamID] = task + ch.chatTasks[chatID] = append(ch.chatTasks[chatID], task) + ch.taskMu.Unlock() + return task +} + +// TestGetStreamResponse_ImmediateAnswer verifies that when the agent has already +// placed its answer in answerCh, getStreamResponse returns a finish=true response +// and fully removes the task. +func TestGetStreamResponse_ImmediateAnswer(t *testing.T) { + ch := makeWebhookChannel(t) + defer ch.cancel() + + task := makeStreamTask(t, ch, "stream-1", "chat-1", time.Now().Add(30*time.Second)) + task.answerCh <- "hello from agent" + + result := ch.getStreamResponse(task, "ts123", "nonce123") + if result == "" { + t.Fatal("expected non-empty encrypted response") + } + + ch.taskMu.RLock() + _, exists := ch.streamTasks["stream-1"] + ch.taskMu.RUnlock() + if exists { + t.Error("task should have been removed from streamTasks after normal finish") + } + if !task.Finished { + t.Error("task.Finished should be true after normal finish") + } +} + +// TestGetStreamResponse_DeadlinePassed verifies that when the stream deadline has +// elapsed (no agent reply yet), getStreamResponse closes the stream but keeps the +// task alive so the response_url fallback can still deliver the answer. +func TestGetStreamResponse_DeadlinePassed(t *testing.T) { + ch := makeWebhookChannel(t) + defer ch.cancel() + + task := makeStreamTask(t, ch, "stream-2", "chat-2", time.Now().Add(-time.Millisecond)) + + result := ch.getStreamResponse(task, "ts456", "nonce456") + if result == "" { + t.Fatal("expected non-empty encrypted response") + } + + ch.taskMu.RLock() + _, stillStreaming := ch.streamTasks["stream-2"] + ch.taskMu.RUnlock() + if stillStreaming { + t.Error("task should have been removed from streamTasks after deadline") + } + if !task.StreamClosed { + t.Error("task.StreamClosed should be true after deadline") + } + if task.Finished { + t.Error("task.Finished must remain false: agent reply still expected via response_url") + } +} + +// TestGetStreamResponse_StillPending verifies that when neither the agent has +// replied nor the deadline has passed, getStreamResponse returns without altering +// task state (client should poll again). +func TestGetStreamResponse_StillPending(t *testing.T) { + ch := makeWebhookChannel(t) + defer ch.cancel() + + task := makeStreamTask(t, ch, "stream-3", "chat-3", time.Now().Add(30*time.Second)) + + result := ch.getStreamResponse(task, "ts789", "nonce789") + if result == "" { + t.Fatal("expected non-empty encrypted response") + } + + ch.taskMu.RLock() + _, exists := ch.streamTasks["stream-3"] + ch.taskMu.RUnlock() + if !exists { + t.Error("pending task should still be in streamTasks") + } + if task.Finished || task.StreamClosed { + t.Error("pending task should not be finished or stream-closed") + } + // Cleanup. + ch.removeTask(task) +} diff --git a/pkg/channels/wecom/aibot_ws.go b/pkg/channels/wecom/aibot_ws.go new file mode 100644 index 000000000..c0eac0687 --- /dev/null +++ b/pkg/channels/wecom/aibot_ws.go @@ -0,0 +1,1346 @@ +package wecom + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/utils" +) + +// Long-connection WebSocket endpoint. +// Ref: https://developer.work.weixin.qq.com/document/path/101463 +const ( + wsEndpoint = "wss://openws.work.weixin.qq.com" + wsHeartbeatInterval = 30 * time.Second + wsConnectTimeout = 15 * time.Second + wsSubscribeTimeout = 10 * time.Second + wsSendMsgTimeout = 10 * time.Second + wsRespondMsgTimeout = 10 * time.Second + wsWelcomeMsgTimeout = 5 * time.Second // WeCom requires welcome reply within 5 seconds + wsMaxReconnectWait = 60 * time.Second + wsInitialReconnect = time.Second + + // WeCom requires finish=true within 6 minutes of the first stream frame. + // wsStreamTickInterval controls how often we send an in-progress hint. + // wsStreamMaxDuration is a safety margin below the 6-minute hard limit. + wsStreamTickInterval = 30 * time.Second + wsStreamMaxDuration = 5*time.Minute + 30*time.Second + + // wsImageDownloadTimeout caps the time we spend downloading an inbound image. + wsImageDownloadTimeout = 30 * time.Second + + // Keep req_id -> chat route for late fallback pushes after stream window closes. + wsLateReplyRouteTTL = 30 * time.Minute + + // wsStreamMaxContentBytes is the maximum UTF-8 byte length for the content field + // of a single WeCom AI Bot stream / text / markdown frame. + // Ref: https://developer.work.weixin.qq.com/document/path/101463 + wsStreamMaxContentBytes = 20480 +) + +// wsImageHTTPClient is a shared HTTP client for downloading inbound images. +// Reusing it enables connection pooling across multiple image downloads. +var wsImageHTTPClient = &http.Client{Timeout: wsImageDownloadTimeout} + +// WeComAIBotWSChannel implements channels.Channel for WeCom AI Bot using the +// WebSocket long-connection API. +// Unlike the webhook counterpart it does NOT implement WebhookHandler, so the +// HTTP manager will not register any callback URL for it. +type WeComAIBotWSChannel struct { + *channels.BaseChannel + config config.WeComAIBotConfig + ctx context.Context + cancel context.CancelFunc + + // conn is the active WebSocket connection; nil when disconnected. + // All writes are serialized through connMu. + conn *websocket.Conn + connMu sync.Mutex + + // dedupe prevents duplicate message processing (WeCom may re-deliver). + dedupe *MessageDeduplicator + + // reqStates holds per-req_id runtime state. + // It unifies active task state and late-reply fallback routing. + reqStates map[string]*wsReqState + reqStatesMu sync.Mutex + + // reqPending correlates command req_ids with response channels. + // Used only for subscribe/ping command-response pairs. + reqPending map[string]chan wsEnvelope + reqPendingMu sync.Mutex +} + +// wsTask tracks one in-progress agent reply for a single chat turn. +type wsTask struct { + ReqID string // req_id echoed in all replies for this turn + ChatID string + ChatType uint32 + StreamID string // our generated stream.id + answerCh chan string // agent delivers its reply here via Send() + ctx context.Context + cancel context.CancelFunc +} + +type wsReqState struct { + Task *wsTask + Route wsLateReplyRoute +} + +type wsLateReplyRoute struct { + ChatID string + ChatType uint32 + ReadyAt time.Time + ExpiresAt time.Time +} + +// ---- WebSocket protocol types ---- + +// wsEnvelope is the generic JSON envelope for all WebSocket messages. +type wsEnvelope struct { + Cmd string `json:"cmd,omitempty"` + Headers wsHeaders `json:"headers"` + Body json.RawMessage `json:"body,omitempty"` + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` +} + +type wsHeaders struct { + ReqID string `json:"req_id"` +} + +// wsCommand is an outgoing request sent over the WebSocket. +type wsCommand struct { + Cmd string `json:"cmd"` + Headers wsHeaders `json:"headers"` + Body any `json:"body,omitempty"` +} + +type wsSendMsgBody struct { + ChatID string `json:"chatid"` + ChatType uint32 `json:"chat_type,omitempty"` + MsgType string `json:"msgtype"` + Markdown *wsMarkdownContent `json:"markdown,omitempty"` +} + +// wsRespondMsgBody is the body for aibot_respond_msg / aibot_respond_welcome_msg. +type wsRespondMsgBody struct { + MsgType string `json:"msgtype"` + Stream *wsStreamContent `json:"stream,omitempty"` + Text *wsTextContent `json:"text,omitempty"` + Markdown *wsMarkdownContent `json:"markdown,omitempty"` + Image *wsImageContent `json:"image,omitempty"` +} + +type wsStreamContent struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` +} + +// wsImageContent carries a base64-encoded image payload for outbound messages. +type wsImageContent struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` +} + +type wsTextContent struct { + Content string `json:"content"` +} + +type wsMarkdownContent struct { + Content string `json:"content"` +} + +// WeComAIBotWSMessage is the decoded body of aibot_msg_callback / +// aibot_event_callback in WebSocket long-connection mode. +// The structure mirrors WeComAIBotMessage but includes extra fields +// that only appear in long-connection callbacks (Voice, AESKey on Image/File). +type WeComAIBotWSMessage struct { + MsgID string `json:"msgid"` + CreateTime int64 `json:"create_time,omitempty"` + AIBotID string `json:"aibotid"` + ChatID string `json:"chatid,omitempty"` + ChatType string `json:"chattype,omitempty"` // "single" | "group" + From struct { + UserID string `json:"userid"` + } `json:"from"` + MsgType string `json:"msgtype"` + Text *struct { + Content string `json:"content"` + } `json:"text,omitempty"` + Image *struct { + URL string `json:"url"` + AESKey string `json:"aeskey,omitempty"` // long-connection: per-resource decrypt key + } `json:"image,omitempty"` + Voice *struct { + Content string `json:"content"` // WeCom transcribes voice to text in callbacks + } `json:"voice,omitempty"` + Mixed *struct { + MsgItem []struct { + MsgType string `json:"msgtype"` + Text *struct { + Content string `json:"content"` + } `json:"text,omitempty"` + Image *struct { + URL string `json:"url"` + AESKey string `json:"aeskey,omitempty"` + } `json:"image,omitempty"` + } `json:"msg_item"` + } `json:"mixed,omitempty"` + Event *struct { + EventType string `json:"eventtype"` + } `json:"event,omitempty"` + File *struct { + URL string `json:"url"` + AESKey string `json:"aeskey,omitempty"` + } `json:"file,omitempty"` + Video *struct { + URL string `json:"url"` + AESKey string `json:"aeskey,omitempty"` + } `json:"video,omitempty"` +} + +// ---- Constructor ---- + +// newWeComAIBotWSChannel creates a WeComAIBotWSChannel for WebSocket mode. +func newWeComAIBotWSChannel( + cfg config.WeComAIBotConfig, + messageBus *bus.MessageBus, +) (*WeComAIBotWSChannel, error) { + if cfg.BotID == "" || cfg.Secret() == "" { + return nil, fmt.Errorf("bot_id and secret are required for WeCom AI Bot WebSocket mode") + } + + base := channels.NewBaseChannel("wecom_aibot", cfg, messageBus, cfg.AllowFrom, + channels.WithReasoningChannelID(cfg.ReasoningChannelID), + ) + + return &WeComAIBotWSChannel{ + BaseChannel: base, + config: cfg, + dedupe: NewMessageDeduplicator(wecomMaxProcessedMessages), + reqStates: make(map[string]*wsReqState), + reqPending: make(map[string]chan wsEnvelope), + }, nil +} + +// ---- Channel interface ---- + +// Name implements channels.Channel. +func (c *WeComAIBotWSChannel) Name() string { return "wecom_aibot" } + +// Start connects to the WeCom WebSocket endpoint and begins message processing. +func (c *WeComAIBotWSChannel) Start(ctx context.Context) error { + logger.InfoC("wecom_aibot", "Starting WeCom AI Bot channel (WebSocket long-connection mode)...") + c.ctx, c.cancel = context.WithCancel(ctx) + c.SetRunning(true) + go c.connectLoop() + logger.InfoC("wecom_aibot", "WeCom AI Bot channel started (WebSocket mode)") + return nil +} + +// Stop shuts down the channel and closes the WebSocket connection. +func (c *WeComAIBotWSChannel) Stop(_ context.Context) error { + logger.InfoC("wecom_aibot", "Stopping WeCom AI Bot channel (WebSocket mode)...") + if c.cancel != nil { + c.cancel() + } + c.connMu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.connMu.Unlock() + c.SetRunning(false) + logger.InfoC("wecom_aibot", "WeCom AI Bot channel stopped") + return nil +} + +// Send delivers the agent reply for msg.ChatID. +// The waiting task goroutine picks it up and writes the final stream response. +func (c *WeComAIBotWSChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + // msg.ChatID carries the inbound req_id (set by dispatchWSAgentTask). + // For cron-triggered messages, msg.ChatID is the real WeCom chat/user ID + // and there will be no matching entry in reqStates; fall through to proactive push. + task, route, ok := c.getReqState(msg.ChatID) + if !ok { + // No req_id record found — this is a cron/scheduler-originated message. + // Send it as a proactive markdown push using the chat ID directly. + logger.InfoCF("wecom_aibot", "Send: no req_id state, delivering via proactive push (cron/scheduler)", + map[string]any{"chat_id": msg.ChatID}) + if err := c.wsSendActivePush(msg.ChatID, 0, msg.Content); err != nil { + logger.WarnCF("wecom_aibot", "Proactive push failed", + map[string]any{"chat_id": msg.ChatID, "error": err.Error()}) + return fmt.Errorf("websocket delivery failed: %w", channels.ErrSendFailed) + } + return nil + } + + if task == nil { + if time.Now().Before(route.ReadyAt) { + // Keep using aibot_respond_msg within stream window; do not proactively + // push unless wsStreamMaxDuration has elapsed. + logger.WarnCF("wecom_aibot", "Send: stream window still open, skip proactive push", + map[string]any{"req_id": msg.ChatID, "ready_at": route.ReadyAt.Format(time.RFC3339)}) + return nil + } + + if err := c.wsSendActivePush(route.ChatID, route.ChatType, msg.Content); err != nil { + logger.WarnCF("wecom_aibot", "Late reply proactive push failed", + map[string]any{"req_id": msg.ChatID, "chat_id": route.ChatID, "error": err.Error()}) + return fmt.Errorf("websocket delivery failed: %w", channels.ErrSendFailed) + } + logger.InfoCF("wecom_aibot", "Late reply delivered via proactive push", + map[string]any{"req_id": msg.ChatID, "chat_id": route.ChatID, "chat_type": route.ChatType}) + c.deleteReqState(msg.ChatID) + return nil + } + + // Non-blocking fast path: when answerCh has space, deliver without racing + // against task.ctx.Done() (which fires when the task is canceled by a new + // incoming message, but the response must still be sent). + select { + case task.answerCh <- msg.Content: + return nil + default: + } + // answerCh was full; block with cancellation guards. + select { + case task.answerCh <- msg.Content: + case <-task.ctx.Done(): + return nil + case <-ctx.Done(): + return ctx.Err() + } + return nil +} + +// ---- Connection management ---- + +// wsBackoffResetDuration is the minimum duration a WebSocket connection must +// stay up before we reset the reconnect backoff to its initial value. This +// prevents a short burst of failures from causing long waits after later, +// stable connection periods. +const wsBackoffResetDuration = time.Minute + +// connectLoop maintains the WebSocket connection, reconnecting on failure with +// exponential backoff. +func (c *WeComAIBotWSChannel) connectLoop() { + backoff := wsInitialReconnect + for { + select { + case <-c.ctx.Done(): + return + default: + } + + logger.InfoC("wecom_aibot", "Connecting to WeCom WebSocket endpoint...") + start := time.Now() + if err := c.runConnection(); err != nil { + elapsed := time.Since(start) + // If the connection was stable for long enough, reset backoff so that + // a previous burst of failures does not keep us at the maximum delay. + if elapsed >= wsBackoffResetDuration { + backoff = wsInitialReconnect + } + select { + case <-c.ctx.Done(): + return + default: + logger.WarnCF("wecom_aibot", "WebSocket connection lost, reconnecting", + map[string]any{"error": err.Error(), "backoff": backoff.String()}) + select { + case <-time.After(backoff): + case <-c.ctx.Done(): + return + } + if backoff < wsMaxReconnectWait { + backoff *= 2 + if backoff > wsMaxReconnectWait { + backoff = wsMaxReconnectWait + } + } + } + } else { + // Clean exit (context canceled); stop reconnecting. + return + } + } +} + +// runConnection dials, subscribes, and runs the read/heartbeat loops until the +// connection closes or the channel context is canceled. +func (c *WeComAIBotWSChannel) runConnection() error { + dialCtx, dialCancel := context.WithTimeout(c.ctx, wsConnectTimeout) + conn, httpResp, err := websocket.DefaultDialer.DialContext(dialCtx, wsEndpoint, nil) + dialCancel() + if httpResp != nil { + httpResp.Body.Close() + } + if err != nil { + return fmt.Errorf("dial failed: %w", err) + } + + c.connMu.Lock() + c.conn = conn + c.connMu.Unlock() + + defer func() { + c.connMu.Lock() + if c.conn == conn { + c.conn = nil + } + c.connMu.Unlock() + // Cancel any tasks that were started over this connection so their + // agent goroutines do not keep running after the connection is gone. + c.cancelAllTasks() + }() + + // ---- Read loop (must start BEFORE subscribing) ---- + // sendAndWait blocks waiting for the subscribe response on reqPending; + // readLoop is the only goroutine that delivers messages to reqPending. + // Starting readLoop first avoids a deadlock where sendAndWait times out + // because no one reads the server's reply. + readErrCh := make(chan error, 1) + go func() { readErrCh <- c.readLoop(conn) }() + + // ---- Subscribe ---- + reqID := wsGenerateID() + resp, err := c.sendAndWait(conn, reqID, wsCommand{ + Cmd: "aibot_subscribe", + Headers: wsHeaders{ReqID: reqID}, + Body: map[string]string{ + "bot_id": c.config.BotID, + "secret": c.config.Secret(), + }, + }, wsSubscribeTimeout) + if err != nil { + conn.Close() // stop readLoop + <-readErrCh + return fmt.Errorf("subscribe failed: %w", err) + } + if resp.ErrCode != 0 { + conn.Close() + <-readErrCh + return fmt.Errorf("subscribe rejected (errcode=%d): %s", resp.ErrCode, resp.ErrMsg) + } + + logger.InfoC("wecom_aibot", "WebSocket subscription successful") + + // ---- Heartbeat goroutine ---- + hbDone := make(chan struct{}) + go func() { + defer close(hbDone) + c.heartbeatLoop(conn) + }() + + // Wait for the read loop to exit, then tear down the heartbeat. + readErr := <-readErrCh + conn.Close() // signal heartbeat to stop (idempotent) + <-hbDone + return readErr +} + +// sendAndWait registers a pending-response slot, sends cmd, and blocks until +// the matching response arrives or the timeout/context fires. +func (c *WeComAIBotWSChannel) sendAndWait( + conn *websocket.Conn, + reqID string, + cmd wsCommand, + timeout time.Duration, +) (wsEnvelope, error) { + ch := make(chan wsEnvelope, 1) + c.reqPendingMu.Lock() + c.reqPending[reqID] = ch + c.reqPendingMu.Unlock() + + cleanup := func() { + c.reqPendingMu.Lock() + delete(c.reqPending, reqID) + c.reqPendingMu.Unlock() + } + + data, err := json.Marshal(cmd) + if err != nil { + cleanup() + return wsEnvelope{}, fmt.Errorf("marshal command: %w", err) + } + c.connMu.Lock() + err = conn.WriteMessage(websocket.TextMessage, data) + c.connMu.Unlock() + if err != nil { + cleanup() + return wsEnvelope{}, fmt.Errorf("write command: %w", err) + } + + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case env := <-ch: + return env, nil + case <-timer.C: + cleanup() + return wsEnvelope{}, fmt.Errorf("timeout waiting for response (req_id=%s)", reqID) + case <-c.ctx.Done(): + cleanup() + return wsEnvelope{}, c.ctx.Err() + } +} + +// heartbeatLoop sends a ping every wsHeartbeatInterval until conn is closed. +// It validates the server's pong response via sendAndWait; a failed pong +// triggers a reconnection by closing the connection. +func (c *WeComAIBotWSChannel) heartbeatLoop(conn *websocket.Conn) { + ticker := time.NewTicker(wsHeartbeatInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + reqID := wsGenerateID() + resp, err := c.sendAndWait(conn, reqID, wsCommand{ + Cmd: "ping", + Headers: wsHeaders{ReqID: reqID}, + }, wsHeartbeatInterval) + if err != nil { + logger.WarnCF("wecom_aibot", "Heartbeat failed, closing connection", + map[string]any{"error": err.Error()}) + conn.Close() + return + } + if resp.ErrCode != 0 { + logger.WarnCF("wecom_aibot", "Heartbeat rejected", + map[string]any{"errcode": resp.ErrCode, "errmsg": resp.ErrMsg}) + conn.Close() + return + } + logger.DebugCF("wecom_aibot", "Heartbeat pong received", map[string]any{"req_id": reqID}) + case <-c.ctx.Done(): + return + } + } +} + +// readLoop reads WebSocket messages and dispatches them until the connection +// closes or the channel is stopped. +func (c *WeComAIBotWSChannel) readLoop(conn *websocket.Conn) error { + for { + _, raw, err := conn.ReadMessage() + if err != nil { + select { + case <-c.ctx.Done(): + return nil // clean shutdown + default: + return fmt.Errorf("read error: %w", err) + } + } + + var env wsEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + logger.WarnCF("wecom_aibot", "Failed to parse WebSocket message", + map[string]any{"error": err.Error(), "raw": string(raw)}) + continue + } + + // Command responses have an empty Cmd field; forward to any waiting + // sendAndWait() call, or silently drop if no one is waiting (e.g. + // late responses after timeout). + if env.Cmd == "" && env.Headers.ReqID != "" { + c.reqPendingMu.Lock() + ch, ok := c.reqPending[env.Headers.ReqID] + if ok { + delete(c.reqPending, env.Headers.ReqID) + } + c.reqPendingMu.Unlock() + if ok { + ch <- env + } + continue + } + + // Dispatch to appropriate handler in a separate goroutine so the + // read loop is never blocked by a slow agent. + go c.handleEnvelope(env) + } +} + +// ---- Message / event handlers ---- + +// handleEnvelope routes a WebSocket envelope to the right handler. +func (c *WeComAIBotWSChannel) handleEnvelope(env wsEnvelope) { + switch env.Cmd { + case "aibot_msg_callback": + c.handleMsgCallback(env) + case "aibot_event_callback": + c.handleEventCallback(env) + default: + logger.DebugCF("wecom_aibot", "Unhandled WebSocket command", + map[string]any{"cmd": env.Cmd}) + } +} + +// handleMsgCallback processes aibot_msg_callback. +func (c *WeComAIBotWSChannel) handleMsgCallback(env wsEnvelope) { + var msg WeComAIBotWSMessage + if err := json.Unmarshal(env.Body, &msg); err != nil { + logger.WarnCF("wecom_aibot", "Failed to parse msg callback body", + map[string]any{"error": err.Error()}) + return + } + + // Deduplicate by msgid (WeCom may re-deliver on network issues). + if msg.MsgID != "" && !c.dedupe.MarkMessageProcessed(msg.MsgID) { + logger.DebugCF("wecom_aibot", "Duplicate message ignored", + map[string]any{"msgid": msg.MsgID}) + return + } + + reqID := env.Headers.ReqID + switch msg.MsgType { + case "text": + c.handleWSTextMessage(reqID, msg) + case "image": + c.handleWSImageMessage(reqID, msg) + case "voice": + c.handleWSVoiceMessage(reqID, msg) + case "mixed": + c.handleWSMixedMessage(reqID, msg) + case "file": + c.handleWSFileMessage(reqID, msg) + case "video": + c.handleWSVideoMessage(reqID, msg) + default: + logger.WarnCF("wecom_aibot", "Unsupported message type", + map[string]any{"msgtype": msg.MsgType}) + c.wsSendStreamFinish(reqID, wsGenerateID(), + "Unsupported message type: "+msg.MsgType) + } +} + +// handleEventCallback processes aibot_event_callback. +func (c *WeComAIBotWSChannel) handleEventCallback(env wsEnvelope) { + var msg WeComAIBotWSMessage + if err := json.Unmarshal(env.Body, &msg); err != nil { + logger.WarnCF("wecom_aibot", "Failed to parse event callback body", + map[string]any{"error": err.Error()}) + return + } + + // Deduplicate by msgid. + if msg.MsgID != "" && !c.dedupe.MarkMessageProcessed(msg.MsgID) { + logger.DebugCF("wecom_aibot", "Duplicate event ignored", + map[string]any{"msgid": msg.MsgID}) + return + } + + var eventType string + if msg.Event != nil { + eventType = msg.Event.EventType + } + logger.DebugCF("wecom_aibot", "Received event callback", + map[string]any{"event_type": eventType}) + + switch eventType { + case "enter_chat": + if c.config.WelcomeMessage != "" { + c.wsSendWelcomeMsg(env.Headers.ReqID, c.config.WelcomeMessage) + } + case "disconnected_event": + // The server will close this connection after sending this event. + // connectLoop will detect the closure and reconnect automatically. + logger.WarnC("wecom_aibot", + "Received disconnected_event: this connection is being replaced by a newer one") + default: + logger.DebugCF("wecom_aibot", "Unhandled event type", + map[string]any{"event_type": eventType}) + } +} + +// handleWSTextMessage dispatches a plain-text message to the agent and streams +// the reply back over the WebSocket connection. +func (c *WeComAIBotWSChannel) handleWSTextMessage(reqID string, msg WeComAIBotWSMessage) { + if msg.Text == nil { + logger.ErrorC("wecom_aibot", "text message missing text field") + return + } + c.dispatchWSAgentTask(reqID, msg, msg.Text.Content, nil) +} + +// handleWSImageMessage downloads and stores the inbound image, then dispatches +// it to the agent as a media-tagged message. +func (c *WeComAIBotWSChannel) handleWSImageMessage(reqID string, msg WeComAIBotWSMessage) { + if msg.Image == nil { + logger.WarnC("wecom_aibot", "Image message missing image field") + c.wsSendStreamFinish(reqID, wsGenerateID(), "Image message could not be processed.") + return + } + c.wsHandleMediaMessage(reqID, msg, msg.Image.URL, msg.Image.AESKey, "image") +} + +// wsHandleMediaMessage is a shared helper for image, file and video messages. +// It downloads the resource, stores it in MediaStore, and dispatches to the agent. +func (c *WeComAIBotWSChannel) wsHandleMediaMessage( + reqID string, msg WeComAIBotWSMessage, + resourceURL, aesKey, label string, +) { + chatID := wsChatID(msg) + + ctx, cancel := context.WithTimeout(c.ctx, wsImageDownloadTimeout) + defer cancel() + + ref, err := c.storeWSMedia(ctx, chatID, msg.MsgID, resourceURL, aesKey, wsLabelToDefaultExt(label)) + if err != nil { + logger.WarnCF("wecom_aibot", "Failed to download/store WS "+label, + map[string]any{"error": err.Error(), "url": resourceURL}) + c.wsSendStreamFinish(reqID, wsGenerateID(), + strings.ToUpper(label[:1])+label[1:]+" message could not be processed.") + return + } + + c.dispatchWSAgentTask(reqID, msg, "["+label+"]", []string{ref}) +} + +// handleWSMixedMessage handles mixed text+image messages. +// All text parts are collected into the content string; all image parts are +// downloaded and stored in MediaStore before dispatching to the agent. +func (c *WeComAIBotWSChannel) handleWSMixedMessage(reqID string, msg WeComAIBotWSMessage) { + if msg.Mixed == nil { + logger.WarnC("wecom_aibot", "Mixed message has no content") + c.wsSendStreamFinish(reqID, wsGenerateID(), "Mixed message type is not yet fully supported.") + return + } + + chatID := wsChatID(msg) + + ctx, cancel := context.WithTimeout(c.ctx, wsImageDownloadTimeout) + defer cancel() + + var textParts []string + var mediaRefs []string + for _, item := range msg.Mixed.MsgItem { + switch item.MsgType { + case "text": + if item.Text != nil && item.Text.Content != "" { + textParts = append(textParts, item.Text.Content) + } + case "image": + if item.Image != nil { + ref, err := c.storeWSMedia(ctx, chatID, + msg.MsgID+"-"+wsGenerateID(), item.Image.URL, item.Image.AESKey, ".jpg") + if err != nil { + logger.WarnCF("wecom_aibot", "Failed to download/store mixed image", + map[string]any{"error": err.Error()}) + } else { + mediaRefs = append(mediaRefs, ref) + } + } + default: + logger.WarnCF("wecom_aibot", "Unsupported item type in mixed message", + map[string]any{"msgtype": item.MsgType}) + } + } + + if len(textParts) == 0 && len(mediaRefs) == 0 { + logger.WarnC("wecom_aibot", "Mixed message has no usable content") + c.wsSendStreamFinish(reqID, wsGenerateID(), "Mixed message type is not yet fully supported.") + return + } + + content := strings.Join(textParts, "\n") + if content == "" { + content = "[images]" + } + c.dispatchWSAgentTask(reqID, msg, content, mediaRefs) +} + +// dispatchWSAgentTask registers a new agent task, sends the opening stream frame, +// and starts a goroutine that runs the agent and streams the reply back. +// content is the text forwarded to the agent; mediaRefs are optional media +// store references attached to the inbound message. +func (c *WeComAIBotWSChannel) dispatchWSAgentTask( + reqID string, + msg WeComAIBotWSMessage, + content string, + mediaRefs []string, +) { + userID := msg.From.UserID + if userID == "" { + userID = "unknown" + } + // actualChatID is the real WeCom chat/user ID used for peer identification. + // reqID is used as the routing chatID so each turn is independently addressable. + actualChatID := wsChatID(msg) + + streamID := wsGenerateID() + chatType := wsChatTypeValue(msg.ChatType) + taskCtx, taskCancel := context.WithCancel(c.ctx) + + task := &wsTask{ + ReqID: reqID, + ChatID: actualChatID, + ChatType: chatType, + StreamID: streamID, + answerCh: make(chan string, 1), + ctx: taskCtx, + cancel: taskCancel, + } + // Each req_id is unique per WeCom turn; tasks run concurrently, no cancellation. + c.setReqState(reqID, &wsReqState{ + Task: task, + Route: wsLateReplyRoute{ + ChatID: actualChatID, + ChatType: chatType, + ReadyAt: time.Now().Add(wsStreamMaxDuration), + ExpiresAt: time.Now().Add(wsLateReplyRouteTTL), + }, + }) + + logger.DebugCF("wecom_aibot", "Registered new agent task", + map[string]any{"chat_id": actualChatID, "req_id": reqID, "stream_id": streamID}) + + // Send an empty stream opening frame (finish=false) immediately. + c.wsSendStreamChunk(reqID, streamID, false, "") + + go func() { + defer func() { + taskCancel() + c.clearReqTask(reqID, task) + }() + + sender := bus.SenderInfo{ + Platform: "wecom_aibot", + PlatformID: userID, + CanonicalID: identity.BuildCanonicalID("wecom_aibot", userID), + DisplayName: userID, + } + peerKind := "direct" + if msg.ChatType == "group" { + peerKind = "group" + } + peer := bus.Peer{Kind: peerKind, ID: actualChatID} + metadata := map[string]string{ + "channel": "wecom_aibot", + "chat_id": actualChatID, + "chat_type": msg.ChatType, + "msg_type": msg.MsgType, + "msgid": msg.MsgID, + "aibotid": msg.AIBotID, + "stream_id": streamID, + } + // Pass reqID as chatID: OutboundMessage.ChatID = reqID → Send() finds tasks[reqID]. + c.HandleMessage(taskCtx, peer, reqID, userID, reqID, + content, mediaRefs, metadata, sender) + + // Wait for the agent reply. While waiting, send periodic finish=false + // hints so the user knows processing is still in progress. + // WeCom requires finish=true within 6 minutes of the first stream frame; + // wsStreamMaxDuration enforces that limit with a safety margin. + waitHints := []string{ + "⏳ Processing, please wait...", + "⏳ Still processing, please wait...", + "⏳ Almost there, please wait...", + } + ticker := time.NewTicker(wsStreamTickInterval) + defer ticker.Stop() + deadlineTimer := time.NewTimer(wsStreamMaxDuration) + defer deadlineTimer.Stop() + tickCount := 0 + for { + select { + case answer := <-task.answerCh: + // Split the answer into byte-bounded chunks and send as stream frames. + // All but the last carry finish=false; the final frame closes the stream. + chunks := splitWSContent(answer, wsStreamMaxContentBytes) + for i, chunk := range chunks { + c.wsSendStreamChunk(reqID, streamID, i == len(chunks)-1, chunk) + } + c.deleteReqState(reqID) + return + case <-ticker.C: + hint := waitHints[tickCount%len(waitHints)] + tickCount++ + logger.DebugCF("wecom_aibot", "Sending stream progress hint", + map[string]any{"chat_id": actualChatID, "tick": tickCount}) + c.wsSendStreamChunk(reqID, streamID, false, hint) + case <-deadlineTimer.C: + logger.WarnCF("wecom_aibot", + "Stream response deadline reached, closing stream; late reply will be pushed", + map[string]any{"chat_id": actualChatID}) + c.wsSendStreamFinish(reqID, streamID, + "⏳ Processing is taking longer than expected, the response will be sent as a follow-up message.") + return + case <-taskCtx.Done(): + // Give a short grace period so that a response queued in the bus + // just before cancellation can still be delivered. This closes a + // race where a rapid second message cancels this task after the + // agent already published but before Send() wrote to answerCh. + // + // The connection is gone at this point, so we cannot use + // wsSendStreamFinish. Try wsSendActivePush on the (possibly + // already-restored) connection; if that also fails, leave the + // route intact so Send() can push the reply once reconnected. + select { + case answer := <-task.answerCh: + if err := c.wsSendActivePush(task.ChatID, task.ChatType, answer); err != nil { + logger.WarnCF("wecom_aibot", + "Grace-period push failed after task cancellation; reply may be lost", + map[string]any{"req_id": reqID, "chat_id": task.ChatID, "error": err.Error()}) + } else { + c.deleteReqState(reqID) + } + case <-time.After(100 * time.Millisecond): + } + return + } + } + }() +} + +// handleWSVoiceMessage handles voice messages. +// WeCom transcribes voice to text in the callback; if the transcription is +// present it is dispatched as plain text to the agent. +func (c *WeComAIBotWSChannel) handleWSVoiceMessage(reqID string, msg WeComAIBotWSMessage) { + if msg.Voice != nil && msg.Voice.Content != "" { + c.dispatchWSAgentTask(reqID, msg, msg.Voice.Content, nil) + return + } + c.wsSendStreamFinish(reqID, wsGenerateID(), "Voice messages are not yet supported.") +} + +// handleWSFileMessage handles file messages. +func (c *WeComAIBotWSChannel) handleWSFileMessage(reqID string, msg WeComAIBotWSMessage) { + if msg.File == nil { + logger.WarnC("wecom_aibot", "File message missing file field") + c.wsSendStreamFinish(reqID, wsGenerateID(), "File message could not be processed.") + return + } + c.wsHandleMediaMessage(reqID, msg, msg.File.URL, msg.File.AESKey, "file") +} + +// handleWSVideoMessage handles video messages. +func (c *WeComAIBotWSChannel) handleWSVideoMessage(reqID string, msg WeComAIBotWSMessage) { + if msg.Video == nil { + logger.WarnC("wecom_aibot", "Video message missing video field") + c.wsSendStreamFinish(reqID, wsGenerateID(), "Video message could not be processed.") + return + } + c.wsHandleMediaMessage(reqID, msg, msg.Video.URL, msg.Video.AESKey, "video") +} + +// ---- WebSocket write helpers ---- + +// wsSendStreamChunk sends an aibot_respond_msg stream frame. +func (c *WeComAIBotWSChannel) wsSendStreamChunk(reqID, streamID string, finish bool, content string) { + logger.DebugCF("wecom_aibot", "Sending stream chunk", map[string]any{ + "stream_id": streamID, + "finish": finish, + "preview": utils.Truncate(content, 100), + }) + cmd := wsCommand{ + Cmd: "aibot_respond_msg", + Headers: wsHeaders{ReqID: reqID}, + Body: wsRespondMsgBody{ + MsgType: "stream", + Stream: &wsStreamContent{ + ID: streamID, + Finish: finish, + Content: content, + }, + }, + } + if err := c.writeWSAndWait(cmd, wsRespondMsgTimeout); err != nil { + logger.WarnCF("wecom_aibot", "Stream chunk ack failed", map[string]any{ + "req_id": reqID, + "stream_id": streamID, + "finish": finish, + "error": err, + }) + } +} + +// wsSendStreamFinish sends the final aibot_respond_msg frame (finish=true, no images). +func (c *WeComAIBotWSChannel) wsSendStreamFinish(reqID, streamID, content string) { + c.wsSendStreamChunk(reqID, streamID, true, content) +} + +// wsSendWelcomeMsg sends a text welcome message via aibot_respond_welcome_msg. +func (c *WeComAIBotWSChannel) wsSendWelcomeMsg(reqID, content string) { + logger.DebugCF("wecom_aibot", "Sending welcome message", map[string]any{"req_id": reqID}) + cmd := wsCommand{ + Cmd: "aibot_respond_welcome_msg", + Headers: wsHeaders{ReqID: reqID}, + Body: wsRespondMsgBody{ + MsgType: "text", + Text: &wsTextContent{Content: content}, + }, + } + if err := c.writeWSAndWait(cmd, wsWelcomeMsgTimeout); err != nil { + logger.WarnCF("wecom_aibot", "Welcome message ack failed", + map[string]any{"req_id": reqID, "error": err.Error()}) + } +} + +// wsSendActivePush sends a proactive markdown message using aibot_send_msg. +// Long content is automatically split into byte-bounded chunks (≤ wsStreamMaxContentBytes +// each) and delivered as consecutive messages. +// It is used as a fallback for late replies after stream response window expires. +func (c *WeComAIBotWSChannel) wsSendActivePush(chatID string, chatType uint32, content string) error { + if chatID == "" { + return fmt.Errorf("chatid is empty") + } + for _, chunk := range splitWSContent(content, wsStreamMaxContentBytes) { + reqID := wsGenerateID() + if err := c.writeWSAndWait(wsCommand{ + Cmd: "aibot_send_msg", + Headers: wsHeaders{ReqID: reqID}, + Body: wsSendMsgBody{ + ChatID: chatID, + ChatType: chatType, + MsgType: "markdown", + Markdown: &wsMarkdownContent{Content: chunk}, + }, + }, wsSendMsgTimeout); err != nil { + return err + } + } + return nil +} + +// writeWSAndWait writes cmd to the active connection and validates the command response. +func (c *WeComAIBotWSChannel) writeWSAndWait(cmd wsCommand, timeout time.Duration) error { + if cmd.Headers.ReqID == "" { + return fmt.Errorf("req_id is empty") + } + + c.connMu.Lock() + conn := c.conn + c.connMu.Unlock() + if conn == nil { + return fmt.Errorf("websocket not connected") + } + + resp, err := c.sendAndWait(conn, cmd.Headers.ReqID, cmd, timeout) + if err != nil { + return err + } + if resp.ErrCode != 0 { + return fmt.Errorf("%s rejected (errcode=%d): %s", cmd.Cmd, resp.ErrCode, resp.ErrMsg) + } + return nil +} + +// cancelAllTasks cancels every pending agent task; called when the connection drops. +// It also expires each task's stream window (ReadyAt = now) so that when the agent +// eventually delivers its reply via Send(), the message is forwarded via +// wsSendActivePush on the restored connection instead of being silently discarded. +func (c *WeComAIBotWSChannel) cancelAllTasks() { + c.reqStatesMu.Lock() + defer c.reqStatesMu.Unlock() + now := time.Now() + for _, state := range c.reqStates { + if state != nil && state.Task != nil { + state.Task.cancel() + state.Task = nil + // Expire the stream window immediately so Send() uses wsSendActivePush. + state.Route.ReadyAt = now + } + } +} + +func (c *WeComAIBotWSChannel) setReqState(reqID string, state *wsReqState) { + c.reqStatesMu.Lock() + defer c.reqStatesMu.Unlock() + now := time.Now() + for k, v := range c.reqStates { + if v == nil || now.After(v.Route.ExpiresAt) { + delete(c.reqStates, k) + } + } + c.reqStates[reqID] = state +} + +func (c *WeComAIBotWSChannel) getReqState(reqID string) (*wsTask, wsLateReplyRoute, bool) { + c.reqStatesMu.Lock() + defer c.reqStatesMu.Unlock() + state, ok := c.reqStates[reqID] + if !ok || state == nil { + return nil, wsLateReplyRoute{}, false + } + if time.Now().After(state.Route.ExpiresAt) { + delete(c.reqStates, reqID) + return nil, wsLateReplyRoute{}, false + } + return state.Task, state.Route, true +} + +func (c *WeComAIBotWSChannel) deleteReqState(reqID string) { + c.reqStatesMu.Lock() + delete(c.reqStates, reqID) + c.reqStatesMu.Unlock() +} + +func (c *WeComAIBotWSChannel) clearReqTask(reqID string, task *wsTask) { + c.reqStatesMu.Lock() + defer c.reqStatesMu.Unlock() + state, ok := c.reqStates[reqID] + if !ok || state == nil { + return + } + if state.Task == task { + state.Task = nil + } +} + +func wsChatTypeValue(chatType string) uint32 { + if chatType == "group" { + return 2 + } + return 1 +} + +// wsChatID returns the effective chat ID from a WS message. +// For group messages it is msg.ChatID; for single chats it falls back to the sender's UserID. +func wsChatID(msg WeComAIBotWSMessage) string { + if msg.ChatID != "" { + return msg.ChatID + } + return msg.From.UserID +} + +// wsGenerateID generates a random 10-character alphanumeric ID. +// It is package-level (not a method) so it can be shared by both channel modes. +func wsGenerateID() string { + return generateRandomID(10) +} + +// ---- Inbound media download helpers ---- + +// storeWSMedia downloads the resource at resourceURL (with optional AES-CBC +// decryption) and stores it in the MediaStore. The file extension is inferred +// from the HTTP Content-Type response header; defaultExt is used as a fallback +// when the content type is absent or unrecognized. +func (c *WeComAIBotWSChannel) storeWSMedia( + ctx context.Context, + chatID, msgID, resourceURL, aesKey, defaultExt string, +) (string, error) { + store := c.GetMediaStore() + if store == nil { + return "", fmt.Errorf("no media store available") + } + + const maxSize = 20 << 20 // 20 MB + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + resp, err := wsImageHTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("download: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download HTTP %d", resp.StatusCode) + } + + // Infer file extension from the Content-Type response header. + ext := wsMediaExtFromContentType(resp.Header.Get("Content-Type")) + if ext == "" { + ext = defaultExt + } + + // Buffer the media in memory, bounded to maxSize. + data, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxSize)+1)) + if err != nil { + return "", fmt.Errorf("read media: %w", err) + } + if len(data) > maxSize { + return "", fmt.Errorf("media too large (> %d MB)", maxSize>>20) + } + + // AES-CBC decryption if a key is present. + if aesKey != "" { + key, decErr := base64.StdEncoding.DecodeString(aesKey) + if decErr != nil || len(key) != 32 { + key, decErr = decodeWeComAESKey(aesKey) + if decErr != nil { + return "", fmt.Errorf("decode media AES key: %w", decErr) + } + } + data, err = decryptAESCBC(key, data) + if err != nil { + return "", fmt.Errorf("decrypt media: %w", err) + } + } + + // Write to a temp file. The file is owned by the MediaStore and deleted by + // store.ReleaseAll — no caller-side cleanup needed. + mediaDir := filepath.Join(os.TempDir(), "picoclaw_media") + if err = os.MkdirAll(mediaDir, 0o700); err != nil { + return "", fmt.Errorf("mkdir: %w", err) + } + tmpFile, err := os.CreateTemp(mediaDir, msgID+"-*"+ext) + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmpFile.Name() + _, writeErr := tmpFile.Write(data) + closeErr := tmpFile.Close() + if writeErr != nil { + os.Remove(tmpPath) + return "", fmt.Errorf("write media: %w", writeErr) + } + if closeErr != nil { + os.Remove(tmpPath) + return "", fmt.Errorf("close media: %w", closeErr) + } + + scope := channels.BuildMediaScope("wecom_aibot", chatID, msgID) + ref, err := store.Store(tmpPath, media.MediaMeta{ + Filename: msgID + ext, + Source: "wecom_aibot", + }, scope) + if err != nil { + os.Remove(tmpPath) + return "", fmt.Errorf("store: %w", err) + } + return ref, nil +} + +// wsMediaExtFromContentType returns the lowercase file extension (with leading +// dot) for the given Content-Type value, or "" when the type is unrecognized. +func wsMediaExtFromContentType(contentType string) string { + if contentType == "" { + return "" + } + // Strip parameters (e.g. "image/jpeg; charset=utf-8" → "image/jpeg"). + mt := strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0])) + switch mt { + case "image/jpeg", "image/jpg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + case "video/mp4": + return ".mp4" + case "video/mpeg", "video/x-mpeg": + return ".mpeg" + case "video/quicktime": + return ".mov" + case "video/webm": + return ".webm" + case "audio/mpeg", "audio/mp3": + return ".mp3" + case "audio/ogg": + return ".ogg" + case "audio/wav": + return ".wav" + case "application/pdf": + return ".pdf" + case "application/zip": + return ".zip" + case "application/x-rar-compressed", "application/vnd.rar": + return ".rar" + case "text/plain": + return ".txt" + case "application/msword": + return ".doc" + case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + return ".docx" + case "application/vnd.ms-excel": + return ".xls" + case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": + return ".xlsx" + case "application/vnd.ms-powerpoint": + return ".ppt" + case "application/vnd.openxmlformats-officedocument.presentationml.presentation": + return ".pptx" + } + return "" +} + +// wsLabelToDefaultExt returns the default file extension for the given media label +// used in wsHandleMediaMessage. It is the fallback when Content-Type detection fails. +func wsLabelToDefaultExt(label string) string { + switch label { + case "image": + return ".jpg" + case "video": + return ".mp4" + default: // "file" and any future labels + return ".bin" + } +} + +// ---- Content length helpers ---- + +// splitWSContent splits content into chunks each fitting within maxBytes UTF-8 +// bytes, preserving code block integrity via channels.SplitMessage. +// When SplitMessage still produces an oversized chunk (e.g. dense CJK content), +// splitAtByteBoundary is applied as a last-resort byte-level fallback. +func splitWSContent(content string, maxBytes int) []string { + if len(content) <= maxBytes { + return []string{content} + } + // SplitMessage works in runes. Use maxBytes as the rune limit: for pure ASCII + // this is exact; for multibyte content the byte verification below catches + // any chunk that still overflows. + chunks := channels.SplitMessage(content, maxBytes) + var result []string + for _, chunk := range chunks { + if len(chunk) <= maxBytes { + result = append(result, chunk) + } else { + // Still too large in bytes (e.g. dense CJK); force-split at UTF-8 boundaries. + result = append(result, splitAtByteBoundary(chunk, maxBytes)...) + } + } + return result +} + +// splitAtByteBoundary splits s into parts each ≤ maxBytes bytes by walking back +// from the hard byte limit to find a valid UTF-8 rune start boundary. +// This is a last-resort fallback; it does not try to preserve code blocks. +func splitAtByteBoundary(s string, maxBytes int) []string { + var parts []string + for len(s) > maxBytes { + end := maxBytes + // Walk back past any UTF-8 continuation bytes (high two bits == 10). + for end > 0 && s[end]>>6 == 0b10 { + end-- + } + if end == 0 { + end = maxBytes // shouldn't happen with valid UTF-8 + } + parts = append(parts, s[:end]) + s = strings.TrimLeft(s[end:], " \t\n\r") + } + if s != "" { + parts = append(parts, s) + } + return parts +} diff --git a/pkg/channels/wecom/aibot_ws_test.go b/pkg/channels/wecom/aibot_ws_test.go new file mode 100644 index 000000000..f2f8833a1 --- /dev/null +++ b/pkg/channels/wecom/aibot_ws_test.go @@ -0,0 +1,295 @@ +package wecom + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" +) + +// newTestWSChannel creates a WeComAIBotWSChannel ready for unit testing. +func newTestWSChannel(t *testing.T) *WeComAIBotWSChannel { + t.Helper() + cfg := config.WeComAIBotConfig{ + Enabled: true, + BotID: "test_bot_id", + } + cfg.SetSecret("test_secret") + ch, err := newWeComAIBotWSChannel(cfg, bus.NewMessageBus()) + if err != nil { + t.Fatalf("create WS channel: %v", err) + } + return ch +} + +// TestStoreWSMedia_NilStore verifies that storeWSMedia returns an error when no +// MediaStore has been injected. +func TestStoreWSMedia_NilStore(t *testing.T) { + ch := newTestWSChannel(t) + _, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", "http://any", "", ".jpg") + if err == nil { + t.Fatal("expected error when no MediaStore is set") + } +} + +// TestStoreWSMedia_HTTPError verifies that storeWSMedia propagates HTTP errors +// from the media server. +func TestStoreWSMedia_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer srv.Close() + + ch := newTestWSChannel(t) + ch.SetMediaStore(media.NewFileMediaStore()) + + _, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", srv.URL, "", ".jpg") + if err == nil { + t.Fatal("expected error for HTTP 404") + } +} + +// TestStoreWSMedia_ServerUnavailable verifies that storeWSMedia returns a clear +// error when the media server cannot be reached. +func TestStoreWSMedia_ServerUnavailable(t *testing.T) { + ch := newTestWSChannel(t) + ch.SetMediaStore(media.NewFileMediaStore()) + + // Port 1 is reserved and will refuse the connection immediately. + _, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", "http://127.0.0.1:1", "", ".jpg") + if err == nil { + t.Fatal("expected error for unreachable server") + } +} + +// TestStoreWSMedia_Success_NoAES verifies the happy path: the media is downloaded, +// a media ref is returned, and the file persists and is readable via Resolve until +// ReleaseAll is called. The server returns no Content-Type, so the defaultExt is used. +func TestStoreWSMedia_Success_NoAES(t *testing.T) { + imageData := bytes.Repeat([]byte("x"), 256) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(imageData) + })) + defer srv.Close() + + ch := newTestWSChannel(t) + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + ref, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", srv.URL, "", ".jpg") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if ref == "" { + t.Fatal("expected non-empty ref") + } + + // File must be accessible after storeWSMedia returns (no premature deletion). + path, err := store.Resolve(ref) + if err != nil { + t.Fatalf("ref should resolve: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("file should exist at %s: %v", path, err) + } + if !bytes.Equal(got, imageData) { + t.Errorf("content mismatch: got len=%d, want len=%d", len(got), len(imageData)) + } + + // ReleaseAll must delete the file (store owns lifecycle). + scope := channels.BuildMediaScope("wecom_aibot", "chat1", "msg1") + if err := store.ReleaseAll(scope); err != nil { + t.Fatalf("ReleaseAll failed: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("file should have been deleted by ReleaseAll, stat err: %v", err) + } +} + +// TestStoreWSMedia_MultipleMessages verifies that concurrent media messages with +// different msgIDs do not collide and each resolve to distinct files. +func TestStoreWSMedia_MultipleMessages(t *testing.T) { + imageA := bytes.Repeat([]byte("a"), 64) + imageB := bytes.Repeat([]byte("b"), 64) + + srvA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(imageA) + })) + defer srvA.Close() + srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(imageB) + })) + defer srvB.Close() + + ch := newTestWSChannel(t) + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + refA, err := ch.storeWSMedia(context.Background(), "chat1", "msgA", srvA.URL, "", ".jpg") + if err != nil { + t.Fatalf("storeWSMedia A: %v", err) + } + refB, err := ch.storeWSMedia(context.Background(), "chat1", "msgB", srvB.URL, "", ".jpg") + if err != nil { + t.Fatalf("storeWSMedia B: %v", err) + } + if refA == refB { + t.Fatal("distinct messages must produce distinct refs") + } + + pathA, _ := store.Resolve(refA) + pathB, _ := store.Resolve(refB) + if pathA == pathB { + t.Fatal("distinct messages must be stored at distinct paths") + } + + gotA, _ := os.ReadFile(pathA) + gotB, _ := os.ReadFile(pathB) + if !bytes.Equal(gotA, imageA) { + t.Errorf("content mismatch for message A") + } + if !bytes.Equal(gotB, imageB) { + t.Errorf("content mismatch for message B") + } +} + +// TestStoreWSMedia_ContentTypeExt verifies that the file extension is inferred +// from the HTTP Content-Type header and the defaultExt fallback is used when the +// type is absent or unrecognized. +func TestStoreWSMedia_ContentTypeExt(t *testing.T) { + tests := []struct { + contentType string + wantExt string + }{ + {"image/jpeg", ".jpg"}, + {"image/png", ".png"}, + {"video/mp4", ".mp4"}, + {"application/pdf", ".pdf"}, + {"application/zip", ".zip"}, + // With parameters stripped. + {"video/mp4; codecs=avc1", ".mp4"}, + // Unknown type → falls back to defaultExt. + {"", ""}, + {"application/octet-stream", ""}, + } + for _, tc := range tests { + got := wsMediaExtFromContentType(tc.contentType) + if got != tc.wantExt { + t.Errorf("wsMediaExtFromContentType(%q) = %q, want %q", tc.contentType, got, tc.wantExt) + } + } + + // End-to-end: server returns Content-Type: video/mp4, defaultExt is .bin. + // The stored file should carry the .mp4 extension, not .bin. + payload := bytes.Repeat([]byte("v"), 128) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "video/mp4") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(payload) + })) + defer srv.Close() + + ch := newTestWSChannel(t) + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + ref, err := ch.storeWSMedia(context.Background(), "chat1", "vid1", srv.URL, "", ".bin") + if err != nil { + t.Fatalf("storeWSMedia: %v", err) + } + path, err := store.Resolve(ref) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if ext := path[len(path)-4:]; ext != ".mp4" { + t.Errorf("expected .mp4 extension from Content-Type, got %q", ext) + } +} + +// TestSplitWSContent verifies byte-aware splitting of stream content. +func TestSplitWSContent(t *testing.T) { + t.Run("short content is not split", func(t *testing.T) { + chunks := splitWSContent("hello", 20480) + if len(chunks) != 1 || chunks[0] != "hello" { + t.Fatalf("unexpected chunks: %v", chunks) + } + }) + + t.Run("ASCII content split at byte boundary", func(t *testing.T) { + // Build a string just over the limit. + content := strings.Repeat("a", 20481) + chunks := splitWSContent(content, 20480) + if len(chunks) < 2 { + t.Fatalf("expected >= 2 chunks, got %d", len(chunks)) + } + for i, c := range chunks { + if len(c) > 20480 { + t.Errorf("chunk %d has %d bytes, want <= 20480", i, len(c)) + } + } + // Reassembled content must equal the original (possibly without leading + // whitespace that splitWSContent trims between chunks). + joined := strings.Join(chunks, "") + if len(joined) < len(content)-len(chunks) { + t.Errorf("joined length %d too short (original %d)", len(joined), len(content)) + } + }) + + t.Run("CJK content split within byte limit", func(t *testing.T) { + // Each CJK rune is 3 bytes in UTF-8. + // 7000 CJK chars = 21000 bytes, which exceeds 20480. + content := strings.Repeat("\u4e2d", 7000) + chunks := splitWSContent(content, 20480) + if len(chunks) < 2 { + t.Fatalf("expected >= 2 chunks for 21000-byte CJK content, got %d", len(chunks)) + } + for i, c := range chunks { + if len(c) > 20480 { + t.Errorf("chunk %d has %d bytes, want <= 20480", i, len(c)) + } + // Every chunk must be valid UTF-8. + if !strings.ContainsRune(c, '\u4e2d') && len(c) > 0 { + // quick plausibility check — content was pure CJK + } + } + }) +} + +// TestSplitAtByteBoundary verifies the last-resort byte-boundary splitter. +func TestSplitAtByteBoundary(t *testing.T) { + t.Run("ASCII fits in one chunk", func(t *testing.T) { + parts := splitAtByteBoundary("hello world", 100) + if len(parts) != 1 { + t.Fatalf("expected 1 part, got %d", len(parts)) + } + }) + + t.Run("splits at byte boundary, never mid-rune", func(t *testing.T) { + // 10 CJK characters = 30 bytes; split at 20 bytes. + s := strings.Repeat("\u6587", 10) // 10 × 3 bytes = 30 bytes + parts := splitAtByteBoundary(s, 20) + for i, p := range parts { + if len(p) > 20 { + t.Errorf("part %d has %d bytes, want <= 20", i, len(p)) + } + // Must be valid UTF-8 (no torn multi-byte sequences). + for j, r := range p { + if r == '\uFFFD' { + t.Errorf("part %d has replacement rune at position %d: torn UTF-8", i, j) + } + } + } + }) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index b43497948..713ee1bac 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -234,25 +234,37 @@ type RoutingConfig struct { Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model } -type AgentDefaults struct { - Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` - RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` - AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` - Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` - ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` - ModelFallbacks []string `json:"model_fallbacks,omitempty"` - ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` - ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` - MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` - Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` - MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` - SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` - SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` - MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` - Routing *RoutingConfig `json:"routing,omitempty"` +// ToolFeedbackConfig controls whether tool execution details are sent to the +// chat channel as real-time feedback messages. When enabled, every tool call +// produces a short notification with the tool name and its parameters. +type ToolFeedbackConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"` + MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"` } -const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB +type AgentDefaults struct { + Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` + RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` + AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` + Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` + ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` + ModelFallbacks []string `json:"model_fallbacks,omitempty"` + ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` + ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` + MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` + Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` + MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` + SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` + MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` + Routing *RoutingConfig `json:"routing,omitempty"` + ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` +} + +const ( + DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB + DefaultWeComAIBotProcessingMessage = "⏳ Processing, please wait. The results will be sent shortly." +) func (d *AgentDefaults) GetMaxMediaSize() int { if d.MaxMediaSize > 0 { @@ -261,6 +273,19 @@ func (d *AgentDefaults) GetMaxMediaSize() int { return DefaultMaxMediaSize } +// GetToolFeedbackMaxArgsLength returns the max args preview length for tool feedback messages. +func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int { + if d.ToolFeedback.MaxArgsLength > 0 { + return d.ToolFeedback.MaxArgsLength + } + return 300 +} + +// IsToolFeedbackEnabled returns true when tool feedback messages should be sent to the chat. +func (d *AgentDefaults) IsToolFeedbackEnabled() bool { + return d.ToolFeedback.Enabled +} + // GetModelName returns the effective model name for the agent defaults. // It prefers the new "model_name" field but falls back to "model" for backward compatibility. func (d *AgentDefaults) GetModelName() string { @@ -283,6 +308,7 @@ type ChannelsConfig struct { WeComApp WeComAppConfig `json:"wecom_app"` WeComAIBot WeComAIBotConfig `json:"wecom_aibot"` Pico PicoConfig `json:"pico"` + PicoClient PicoClientConfig `json:"pico_client"` IRC IRCConfig `json:"irc"` } @@ -303,6 +329,12 @@ type PlaceholderConfig struct { Text string `json:"text,omitempty"` } +type StreamingConfig struct { + Enabled bool `json:"enabled,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED"` + ThrottleSeconds int `json:"throttle_seconds,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS"` + MinGrowthChars int `json:"min_growth_chars,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS"` +} + type WhatsAppConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` @@ -321,6 +353,7 @@ type TelegramConfig struct { GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` + 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 @@ -672,15 +705,18 @@ func (c *WeComAppConfig) SetEncodingAESKey(key string) { } type WeComAIBotConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"` + 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" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"` - ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"` - MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps - WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"` + WebhookPath string `json:"webhook_path,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"` + ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"` + MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps + WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome + ProcessingMessage string `json:"processing_message,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_PROCESSING_MESSAGE"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"` secDirty bool } @@ -706,6 +742,15 @@ func (c *WeComAIBotConfig) SetEncodingAESKey(key string) { c.secDirty = true } +func (c *WeComAIBotConfig) Secret() string { + return c.secret +} + +func (c *WeComAIBotConfig) SetSecret(secret string) { + c.secret = secret + c.secDirty = true +} + type PicoConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` token string @@ -731,6 +776,16 @@ func (c *PicoConfig) SetToken(token string) { c.secDirty = true } +type PicoClientConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ENABLED"` + URL string `json:"url" env:"PICOCLAW_CHANNELS_PICO_CLIENT_URL"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_CLIENT_TOKEN"` + SessionID string `json:"session_id,omitempty"` + PingInterval int `json:"ping_interval,omitempty"` + ReadTimeout int `json:"read_timeout,omitempty"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ALLOW_FROM"` +} + type IRCConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"` Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"` @@ -1423,10 +1478,13 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error { // Handle WeCom AI Bot credentials if sec.Channels.WeComAIBot != nil { if sec.Channels.WeComAIBot.Token != "" { - cfg.Channels.WeComAIBot.SetToken(sec.Channels.WeComAIBot.Token) + cfg.Channels.WeComAIBot.token = sec.Channels.WeComAIBot.Token } if sec.Channels.WeComAIBot.EncodingAESKey != "" { - cfg.Channels.WeComAIBot.SetEncodingAESKey(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 } } @@ -1653,6 +1711,7 @@ func SaveConfig(path string, cfg *Config) error { 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 } diff --git a/pkg/config/config_old.go b/pkg/config/config_old.go index 28670b0fc..d969cb063 100644 --- a/pkg/config/config_old.go +++ b/pkg/config/config_old.go @@ -504,6 +504,7 @@ func (v *wecomappConfigV0) ToWeComAppConfig() (WeComAppConfig, WeComAppSecurity) 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"` @@ -516,8 +517,6 @@ type wecomaibotConfigV0 struct { func (v *wecomaibotConfigV0) ToWeComAIBotConfig() (WeComAIBotConfig, WeComAIBotSecurity) { return WeComAIBotConfig{ Enabled: v.Enabled, - token: v.Token, - encodingAESKey: v.EncodingAESKey, WebhookPath: v.WebhookPath, AllowFrom: v.AllowFrom, ReplyTimeout: v.ReplyTimeout, @@ -526,6 +525,7 @@ func (v *wecomaibotConfigV0) ToWeComAIBotConfig() (WeComAIBotConfig, WeComAIBotS ReasoningChannelID: v.ReasoningChannelID, }, WeComAIBotSecurity{ Token: v.Token, + Secret: v.Secret, EncodingAESKey: v.EncodingAESKey, } } diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 1923b6dd9..fe2f86361 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -37,6 +37,10 @@ func DefaultConfig() *Config { MaxToolIterations: 50, SummarizeMessageThreshold: 20, SummarizeTokenPercent: 75, + ToolFeedback: ToolFeedbackConfig{ + Enabled: true, + MaxArgsLength: 300, + }, }, }, Bindings: []AgentBinding{}, @@ -59,6 +63,7 @@ func DefaultConfig() *Config { Enabled: true, Text: "Thinking... 💭", }, + Streaming: StreamingConfig{Enabled: true, ThrottleSeconds: 3, MinGrowthChars: 200}, UseMarkdownV2: false, }, Feishu: FeishuConfig{ @@ -142,12 +147,13 @@ func DefaultConfig() *Config { ReplyTimeout: 5, }, WeComAIBot: WeComAIBotConfig{ - Enabled: false, - WebhookPath: "/webhook/wecom-aibot", - AllowFrom: FlexibleStringSlice{}, - ReplyTimeout: 5, - MaxSteps: 10, - WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?", + Enabled: false, + WebhookPath: "/webhook/wecom-aibot", + AllowFrom: FlexibleStringSlice{}, + ReplyTimeout: 5, + MaxSteps: 10, + WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?", + ProcessingMessage: DefaultWeComAIBotProcessingMessage, }, Pico: PicoConfig{ Enabled: false, diff --git a/pkg/config/security.go b/pkg/config/security.go index 8f2018196..ec3333e43 100644 --- a/pkg/config/security.go +++ b/pkg/config/security.go @@ -111,6 +111,7 @@ type WeComAppSecurity struct { } 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"` } diff --git a/pkg/providers/anthropic_messages/provider.go b/pkg/providers/anthropic_messages/provider.go index c201dfe00..2b19e941a 100644 --- a/pkg/providers/anthropic_messages/provider.go +++ b/pkg/providers/anthropic_messages/provider.go @@ -221,6 +221,10 @@ func buildRequestBody( // Add tool_use blocks for _, tc := range msg.ToolCalls { + if strings.TrimSpace(tc.Name) == "" { + continue + } + // Handle nil Arguments (GLM-4 may return null input) input := tc.Arguments if input == nil { diff --git a/pkg/providers/anthropic_messages/provider_test.go b/pkg/providers/anthropic_messages/provider_test.go index da4213e92..8eabc15fa 100644 --- a/pkg/providers/anthropic_messages/provider_test.go +++ b/pkg/providers/anthropic_messages/provider_test.go @@ -492,6 +492,20 @@ func TestBuildRequestBodyEdgeCases(t *testing.T) { }, wantErr: false, }, + { + name: "skip tool calls with empty names", + messages: []Message{ + {Role: "assistant", Content: "Calling tool", ToolCalls: []ToolCall{ + {ID: "tool-empty", Name: "", Arguments: map[string]any{"ignored": true}}, + {ID: "tool-valid", Name: "test_tool", Arguments: map[string]any{"arg": "value"}}, + }}, + }, + model: "test-model", + options: map[string]any{ + "max_tokens": 8192, + }, + wantErr: false, + }, } for _, tt := range tests { @@ -513,6 +527,37 @@ func TestBuildRequestBodyEdgeCases(t *testing.T) { if got["model"] != tt.model { t.Errorf("model = %v, want %v", got["model"], tt.model) } + + if tt.name == "skip tool calls with empty names" { + messages, ok := got["messages"].([]any) + if !ok || len(messages) != 1 { + t.Fatalf("messages = %#v, want single assistant message", got["messages"]) + } + + assistantMsg, ok := messages[0].(map[string]any) + if !ok { + t.Fatalf("assistant message = %#v, want map", messages[0]) + } + + content, ok := assistantMsg["content"].([]any) + if !ok { + t.Fatalf("assistant content = %#v, want []any", assistantMsg["content"]) + } + if len(content) != 2 { + t.Fatalf("assistant content length = %d, want 2", len(content)) + } + + toolUse, ok := content[1].(map[string]any) + if !ok { + t.Fatalf("tool_use block = %#v, want map", content[1]) + } + if gotName := toolUse["name"]; gotName != "test_tool" { + t.Fatalf("tool_use name = %v, want %q", gotName, "test_tool") + } + if gotID := toolUse["id"]; gotID != "tool-valid" { + t.Fatalf("tool_use id = %v, want %q", gotID, "tool-valid") + } + } }) } } diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index ff2cff9d6..8a18f8fe7 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -115,8 +115,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian", - "minimax", "longcat", "modelscope", "novita": + "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", + "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", + "coding-plan", "alibaba-coding", "qwen-coding": // All other OpenAI-compatible HTTP providers if cfg.APIKey() == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) @@ -173,6 +174,21 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.RequestTimeout, ), modelID, nil + case "coding-plan-anthropic", "alibaba-coding-anthropic": + // Alibaba Coding Plan with Anthropic-compatible API + apiBase := cfg.APIBase + if apiBase == "" { + apiBase = getDefaultAPIBase(protocol) + } + if cfg.APIKey() == "" { + return nil, "", fmt.Errorf("api_key is required for %q protocol (model: %s)", protocol, cfg.Model) + } + return anthropicmessages.NewProviderWithTimeout( + cfg.APIKey(), + apiBase, + cfg.RequestTimeout, + ), modelID, nil + case "antigravity": return NewAntigravityProvider(), modelID, nil @@ -245,6 +261,14 @@ func getDefaultAPIBase(protocol string) string { return "https://ark.cn-beijing.volces.com/api/v3" case "qwen": return "https://dashscope.aliyuncs.com/compatible-mode/v1" + case "qwen-intl", "qwen-international", "dashscope-intl": + return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + case "qwen-us", "dashscope-us": + return "https://dashscope-us.aliyuncs.com/compatible-mode/v1" + case "coding-plan", "alibaba-coding", "qwen-coding": + return "https://coding-intl.dashscope.aliyuncs.com/v1" + case "coding-plan-anthropic", "alibaba-coding-anthropic": + return "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" case "vllm": return "http://localhost:8000/v1" case "mistral": diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 9b34b38e3..fb980f32f 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -473,3 +473,134 @@ func TestCreateProviderFromConfig_AzureMissingAPIBase(t *testing.T) { t.Fatal("CreateProviderFromConfig() expected error for missing API base") } } + +func TestCreateProviderFromConfig_QwenInternationalAlias(t *testing.T) { + tests := []struct { + name string + protocol string + }{ + {"qwen-international", "qwen-international"}, + {"dashscope-intl", "dashscope-intl"}, + {"qwen-intl", "qwen-intl"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-" + tt.protocol, + Model: tt.protocol + "/qwen-max", + } + 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 != "qwen-max" { + t.Errorf("modelID = %q, want %q", modelID, "qwen-max") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } + }) + } +} + +func TestCreateProviderFromConfig_QwenUSAlias(t *testing.T) { + tests := []struct { + name string + protocol string + }{ + {"qwen-us", "qwen-us"}, + {"dashscope-us", "dashscope-us"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-" + tt.protocol, + Model: tt.protocol + "/qwen-max", + } + 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 != "qwen-max" { + t.Errorf("modelID = %q, want %q", modelID, "qwen-max") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } + }) + } +} + +func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) { + tests := []struct { + name string + protocol string + }{ + {"coding-plan-anthropic", "coding-plan-anthropic"}, + {"alibaba-coding-anthropic", "alibaba-coding-anthropic"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-" + tt.protocol, + Model: tt.protocol + "/claude-sonnet-4-20250514", + } + 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 != "claude-sonnet-4-20250514" { + t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4-20250514") + } + // coding-plan-anthropic uses Anthropic Messages provider + // Verify it's the anthropic messages provider by checking interface + var _ LLMProvider = provider + }) + } +} + +func TestGetDefaultAPIBase_CodingPlanAnthropic(t *testing.T) { + expectedURL := "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" + if got := getDefaultAPIBase("coding-plan-anthropic"); got != expectedURL { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "coding-plan-anthropic", got, expectedURL) + } + if got := getDefaultAPIBase("alibaba-coding-anthropic"); got != expectedURL { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "alibaba-coding-anthropic", got, expectedURL) + } +} + +func TestGetDefaultAPIBase_QwenIntlAliases(t *testing.T) { + expectedURL := "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + for _, protocol := range []string{"qwen-intl", "qwen-international", "dashscope-intl"} { + if got := getDefaultAPIBase(protocol); got != expectedURL { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", protocol, got, expectedURL) + } + } +} + +func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) { + expectedURL := "https://dashscope-us.aliyuncs.com/compatible-mode/v1" + for _, protocol := range []string{"qwen-us", "dashscope-us"} { + if got := getDefaultAPIBase(protocol); got != expectedURL { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", protocol, got, expectedURL) + } + } +} diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 4d823630e..803165edb 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -52,6 +52,19 @@ func (p *HTTPProvider) Chat( return p.delegate.Chat(ctx, messages, tools, model, options) } +// ChatStream implements providers.StreamingProvider by delegating to the +// OpenAI-compatible streaming endpoint (SSE with stream: true). +func (p *HTTPProvider) ChatStream( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + onChunk func(accumulated string), +) (*LLMResponse, error) { + return p.delegate.ChatStream(ctx, messages, tools, model, options, onChunk) +} + func (p *HTTPProvider) GetDefaultModel() string { return "" } diff --git a/pkg/providers/model_ref.go b/pkg/providers/model_ref.go index 0d1b02d16..be9f63bc6 100644 --- a/pkg/providers/model_ref.go +++ b/pkg/providers/model_ref.go @@ -53,6 +53,14 @@ func NormalizeProvider(provider string) string { return "zhipu" case "google": return "gemini" + case "alibaba-coding", "qwen-coding": + return "coding-plan" + case "alibaba-coding-anthropic": + return "coding-plan-anthropic" + case "qwen-international", "dashscope-intl": + return "qwen-intl" + case "dashscope-us": + return "qwen-us" } return p diff --git a/pkg/providers/model_ref_test.go b/pkg/providers/model_ref_test.go index 6dd25167f..040c511ba 100644 --- a/pkg/providers/model_ref_test.go +++ b/pkg/providers/model_ref_test.go @@ -73,6 +73,14 @@ func TestNormalizeProvider(t *testing.T) { {"glm", "zhipu"}, {"google", "gemini"}, {"groq", "groq"}, + // Alibaba Coding Plan aliases + {"alibaba-coding", "coding-plan"}, + {"qwen-coding", "coding-plan"}, + {"alibaba-coding-anthropic", "coding-plan-anthropic"}, + // Qwen international aliases + {"qwen-international", "qwen-intl"}, + {"dashscope-intl", "qwen-intl"}, + {"dashscope-us", "qwen-us"}, {"", ""}, } diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 463db83c9..938e4ea8b 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -1,10 +1,13 @@ package openai_compat import ( + "bufio" "bytes" "context" "encoding/json" "fmt" + "io" + "log" "net/http" "net/url" "strings" @@ -85,17 +88,10 @@ func NewProviderWithMaxTokensFieldAndTimeout( ) } -func (p *Provider) Chat( - ctx context.Context, - messages []Message, - tools []ToolDefinition, - model string, - options map[string]any, -) (*LLMResponse, error) { - if p.apiBase == "" { - return nil, fmt.Errorf("API base not configured") - } - +// buildRequestBody constructs the common request body for Chat and ChatStream. +func (p *Provider) buildRequestBody( + messages []Message, tools []ToolDefinition, model string, options map[string]any, +) map[string]any { model = normalizeModel(model, p.apiBase) requestBody := map[string]any{ @@ -112,10 +108,8 @@ func (p *Provider) Chat( } if maxTokens, ok := common.AsInt(options["max_tokens"]); ok { - // Use configured maxTokensField if specified, otherwise fallback to model-based detection fieldName := p.maxTokensField if fieldName == "" { - // Fallback: detect from model name for backward compatibility lowerModel := strings.ToLower(model) if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") || strings.Contains(lowerModel, "gpt-5") { @@ -129,7 +123,6 @@ func (p *Provider) Chat( if temperature, ok := common.AsFloat(options["temperature"]); ok { lowerModel := strings.ToLower(model) - // Kimi k2 models only support temperature=1. if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") { requestBody["temperature"] = 1.0 } else { @@ -139,17 +132,30 @@ func (p *Provider) Chat( // Prompt caching: pass a stable cache key so OpenAI can bucket requests // with the same key and reuse prefix KV cache across calls. - // The key is typically the agent ID — stable per agent, shared across requests. - // See: https://platform.openai.com/docs/guides/prompt-caching // Prompt caching is only supported by OpenAI-native endpoints. - // Non-OpenAI providers (Mistral, Gemini, DeepSeek, etc.) reject unknown - // fields with 422 errors, so only include it for OpenAI APIs. + // Non-OpenAI providers reject unknown fields with 422 errors. if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" { if supportsPromptCacheKey(p.apiBase) { requestBody["prompt_cache_key"] = cacheKey } } + return requestBody +} + +func (p *Provider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { + if p.apiBase == "" { + return nil, fmt.Errorf("API base not configured") + } + + requestBody := p.buildRequestBody(messages, tools, model, options) + jsonData, err := json.Marshal(requestBody) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) @@ -178,6 +184,195 @@ func (p *Provider) Chat( return common.ReadAndParseResponse(resp, p.apiBase) } +// ChatStream implements streaming via OpenAI-compatible SSE (stream: true). +// onChunk receives the accumulated text so far on each text delta. +func (p *Provider) ChatStream( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + onChunk func(accumulated string), +) (*LLMResponse, error) { + if p.apiBase == "" { + return nil, fmt.Errorf("API base not configured") + } + + requestBody := p.buildRequestBody(messages, tools, model, options) + requestBody["stream"] = true + + jsonData, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + if p.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+p.apiKey) + } + + // Use a client without Timeout for streaming — the http.Client.Timeout covers + // the entire request lifecycle including body reads, which would kill long streams. + // Context cancellation still provides the safety net. + streamClient := &http.Client{Transport: p.httpClient.Transport} + resp, err := streamClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, common.HandleErrorResponse(resp, p.apiBase) + } + + return parseStreamResponse(ctx, resp.Body, onChunk) +} + +// parseStreamResponse parses an OpenAI-compatible SSE stream. +func parseStreamResponse( + ctx context.Context, + reader io.Reader, + onChunk func(accumulated string), +) (*LLMResponse, error) { + var textContent strings.Builder + var finishReason string + var usage *UsageInfo + + // Tool call assembly: OpenAI streams tool calls as incremental deltas + type toolAccum struct { + id string + name string + argsJSON strings.Builder + } + activeTools := map[int]*toolAccum{} + + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) // 1MB initial, 10MB max + for scanner.Scan() { + // Check for context cancellation between chunks + if err := ctx.Err(); err != nil { + return nil, err + } + + line := scanner.Text() + + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + var chunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + ToolCalls []struct { + Index int `json:"index"` + ID string `json:"id"` + Function *struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` + Usage *UsageInfo `json:"usage"` + } + + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue // skip malformed chunks + } + + if chunk.Usage != nil { + usage = chunk.Usage + } + + if len(chunk.Choices) == 0 { + continue + } + + choice := chunk.Choices[0] + + // Accumulate text content + if choice.Delta.Content != "" { + textContent.WriteString(choice.Delta.Content) + if onChunk != nil { + onChunk(textContent.String()) + } + } + + // Accumulate tool call deltas + for _, tc := range choice.Delta.ToolCalls { + acc, ok := activeTools[tc.Index] + if !ok { + acc = &toolAccum{} + activeTools[tc.Index] = acc + } + if tc.ID != "" { + acc.id = tc.ID + } + if tc.Function != nil { + if tc.Function.Name != "" { + acc.name = tc.Function.Name + } + if tc.Function.Arguments != "" { + acc.argsJSON.WriteString(tc.Function.Arguments) + } + } + } + + if choice.FinishReason != nil { + finishReason = *choice.FinishReason + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("streaming read error: %w", err) + } + + // Assemble tool calls from accumulated deltas + var toolCalls []ToolCall + for i := 0; i < len(activeTools); i++ { + acc, ok := activeTools[i] + if !ok { + continue + } + args := make(map[string]any) + raw := acc.argsJSON.String() + if raw != "" { + if err := json.Unmarshal([]byte(raw), &args); err != nil { + log.Printf("openai_compat stream: failed to decode tool call arguments for %q: %v", acc.name, err) + args["raw"] = raw + } + } + toolCalls = append(toolCalls, ToolCall{ + ID: acc.id, + Name: acc.name, + Arguments: args, + }) + } + + if finishReason == "" { + finishReason = "stop" + } + + return &LLMResponse{ + Content: textContent.String(), + ToolCalls: toolCalls, + FinishReason: finishReason, + Usage: usage, + }, nil +} + func normalizeModel(model, apiBase string) string { before, after, ok := strings.Cut(model, "/") if !ok { diff --git a/pkg/providers/types.go b/pkg/providers/types.go index 1f28bc4ad..9a4d126a7 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -37,6 +37,20 @@ type StatefulProvider interface { Close() } +// StreamingProvider is an optional interface for providers that support token streaming. +// onChunk receives the accumulated text so far (not individual deltas). +// The returned LLMResponse is the same complete response for compatibility with tool-call handling. +type StreamingProvider interface { + ChatStream( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + onChunk func(accumulated string), + ) (*LLMResponse, error) +} + // ThinkingCapable is an optional interface for providers that support // extended thinking (e.g. Anthropic). Used by the agent loop to warn // when thinking_level is configured but the active provider cannot use it. diff --git a/web/frontend/package.json b/web/frontend/package.json index 2e0e37117..b1cc09b7b 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -14,8 +14,8 @@ }, "dependencies": { "@fontsource-variable/inter": "^5.2.8", - "@tabler/icons-react": "^3.38.0", - "@tailwindcss/vite": "^4.2.1", + "@tabler/icons-react": "^3.40.0", + "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.167.0", "@tanstack/react-router-devtools": "^1.163.3", @@ -32,32 +32,32 @@ "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", "remark-gfm": "^4.0.1", - "shadcn": "^4.0.5", + "shadcn": "^4.1.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.1", + "tailwindcss": "^4.2.2", "tw-animate-css": "^1.4.0", "wrap-ansi": "^10.0.0" }, "devDependencies": { - "@eslint/js": "^9.39.3", + "@eslint/js": "^9.39.4", "@tailwindcss/typography": "^0.5.19", "@tanstack/router-plugin": "^1.164.0", "@trivago/prettier-plugin-sort-imports": "^6.0.2", - "@types/node": "^24.10.1", + "@types/node": "^25.5.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.56.1", + "@typescript-eslint/eslint-plugin": "^8.57.1", "@vitejs/plugin-react": "^5.2.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", + "eslint-plugin-react-refresh": "^0.4.26", "globals": "^16.5.0", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", "typescript": "~5.9.3", - "typescript-eslint": "^8.48.0", + "typescript-eslint": "^8.57.1", "vite": "^7.3.1" } } diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 20f0a7342..f893abda9 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -12,20 +12,20 @@ importers: specifier: ^5.2.8 version: 5.2.8 '@tabler/icons-react': - specifier: ^3.38.0 - version: 3.38.0(react@19.2.4) + specifier: ^3.40.0 + version: 3.40.0(react@19.2.4) '@tailwindcss/vite': - specifier: ^4.2.1 - version: 4.2.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + specifier: ^4.2.2 + version: 4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) '@tanstack/react-query': specifier: ^5.90.21 - version: 5.90.21(react@19.2.4) + version: 5.91.2(react@19.2.4) '@tanstack/react-router': specifier: ^1.167.0 - version: 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-router-devtools': specifier: ^1.163.3 - version: 1.163.3(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.0)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.166.9(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.5)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -37,7 +37,7 @@ importers: version: 1.11.20 i18next: specifier: ^25.8.14 - version: 25.8.14(typescript@5.9.3) + version: 25.8.20(typescript@5.9.3) i18next-browser-languagedetector: specifier: ^8.2.1 version: 8.2.1 @@ -55,7 +55,7 @@ importers: version: 19.2.4(react@19.2.4) react-i18next: specifier: ^16.5.8 - version: 16.5.8(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + version: 16.5.8(i18next@25.8.20(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) @@ -66,8 +66,8 @@ importers: specifier: ^4.0.1 version: 4.0.1 shadcn: - specifier: ^4.0.5 - version: 4.0.5(@types/node@24.11.0)(typescript@5.9.3) + specifier: ^4.1.0 + version: 4.1.0(@types/node@25.5.0)(typescript@5.9.3) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -75,8 +75,8 @@ importers: specifier: ^3.5.0 version: 3.5.0 tailwindcss: - specifier: ^4.2.1 - version: 4.2.1 + specifier: ^4.2.2 + version: 4.2.2 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -85,20 +85,20 @@ importers: version: 10.0.0 devDependencies: '@eslint/js': - specifier: ^9.39.3 - version: 9.39.3 + specifier: ^9.39.4 + version: 9.39.4 '@tailwindcss/typography': specifier: ^0.5.19 - version: 0.5.19(tailwindcss@4.2.1) + version: 0.5.19(tailwindcss@4.2.2) '@tanstack/router-plugin': specifier: ^1.164.0 - version: 1.164.0(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + version: 1.166.14(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 version: 6.0.2(prettier@3.8.1) '@types/node': - specifier: ^24.10.1 - version: 24.11.0 + specifier: ^25.5.0 + version: 25.5.0 '@types/react': specifier: ^19.2.7 version: 19.2.14 @@ -106,23 +106,23 @@ importers: specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) '@typescript-eslint/eslint-plugin': - specifier: ^8.56.1 - version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + specifier: ^8.57.1 + version: 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-react': specifier: ^5.2.0 - version: 5.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + version: 5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) eslint: - specifier: ^9.39.3 - version: 9.39.3(jiti@2.6.1) + specifier: ^9.39.4 + version: 9.39.4(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.3(jiti@2.6.1)) + version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: specifier: ^7.0.1 - version: 7.0.1(eslint@9.39.3(jiti@2.6.1)) + version: 7.0.1(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-refresh: - specifier: ^0.4.24 - version: 0.4.26(eslint@9.39.3(jiti@2.6.1)) + specifier: ^0.4.26 + version: 0.4.26(eslint@9.39.4(jiti@2.6.1)) globals: specifier: ^16.5.0 version: 16.5.0 @@ -136,18 +136,14 @@ importers: specifier: ~5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.48.0 - version: 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + specifier: ^8.57.1 + version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + version: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) packages: - '@antfu/ni@25.0.0': - resolution: {integrity: sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==} - hasBin: true - '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -226,12 +222,12 @@ packages: resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.0': - resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} engines: {node: '>=6.0.0'} hasBin: true @@ -277,8 +273,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.28.6': - resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} '@babel/template@7.28.6': @@ -293,8 +289,8 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@dotenvx/dotenvx@1.52.0': - resolution: {integrity: sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==} + '@dotenvx/dotenvx@1.57.0': + resolution: {integrity: sha512-WsTEcqfHzKmLFZh3jLGd7o4iCkrIupp+qFH2FJUJtQXUh2GcOnLXD00DcrhlO4H8QSmaKnW9lugOEbrdpu25kA==} hasBin: true '@ecies/ciphers@0.2.5': @@ -303,158 +299,158 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -485,8 +481,8 @@ packages: resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.3': - resolution: {integrity: sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==} + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -497,20 +493,20 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.7.4': - resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - '@floating-ui/dom@1.7.5': - resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - '@floating-ui/react-dom@2.1.7': - resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/utils@0.2.10': - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} '@fontsource-variable/inter@5.2.8': resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} @@ -1460,73 +1456,73 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@tabler/icons-react@3.38.0': - resolution: {integrity: sha512-kR5wv+m4+GgmnSszg3rQd6SrTFAQ/XnQC/yTwIfuRJSfqB12KoIC7fPbIijFgOHTFlBN5DARnN0IVrR7KYG6/A==} + '@tabler/icons-react@3.40.0': + resolution: {integrity: sha512-oO5+6QCnna4a//mYubx4euZfECtzQZFDGsDMIdzZUhbdyBCT+3bRVFBPueGIcemWld4Vb/0UQ39C/cmGfGylAg==} peerDependencies: react: '>= 16' - '@tabler/icons@3.38.0': - resolution: {integrity: sha512-FdETQSpQ3lN7BEjEUzjKhsfTDCamrvMDops4HEMphTm3DmkIFpThoODn8XXZ8Q9MhjshIvphIYVHHB7zpq167w==} + '@tabler/icons@3.40.0': + resolution: {integrity: sha512-V/Q4VgNPKubRTiLdmWjV/zscYcj5IIk+euicUtaVVqF6luSC9rDngYWgST5/yh3Mrg/mYUwRv1YVTk71Jp0twQ==} - '@tailwindcss/node@4.2.1': - resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} - '@tailwindcss/oxide-android-arm64@4.2.1': - resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.1': - resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.1': - resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.1': - resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': - resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': - resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-arm64-musl@4.2.1': - resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-x64-gnu@4.2.1': - resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-linux-x64-musl@4.2.1': - resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-wasm32-wasi@4.2.1': - resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -1537,20 +1533,20 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': - resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.1': - resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.1': - resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} engines: {node: '>= 20'} '@tailwindcss/typography@0.5.19': @@ -1558,37 +1554,37 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tailwindcss/vite@4.2.1': - resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} + '@tailwindcss/vite@4.2.2': + resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} peerDependencies: - vite: ^5.2.0 || ^6 || ^7 + vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tanstack/history@1.161.4': - resolution: {integrity: sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww==} + '@tanstack/history@1.161.6': + resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} engines: {node: '>=20.19'} - '@tanstack/query-core@5.90.20': - resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + '@tanstack/query-core@5.91.2': + resolution: {integrity: sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==} - '@tanstack/react-query@5.90.21': - resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + '@tanstack/react-query@5.91.2': + resolution: {integrity: sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==} peerDependencies: react: ^18 || ^19 - '@tanstack/react-router-devtools@1.163.3': - resolution: {integrity: sha512-42VMkV/2Z8ro7xzblPBRNZIEmCNXMzm2jD68G52p2qhjXm38wGpg46qneAESN9FtTQeVWk5aSXs47/jt7lkzmw==} + '@tanstack/react-router-devtools@1.166.9': + resolution: {integrity: sha512-O49eZmaeEKB5YnKH/qd61AbxV/lW8ICm4stfZ4GNQNpzQQ6rhPIB0p3PMZDIgX+6DoMivdNvLRmXAOOpzpIpDg==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/react-router': ^1.163.3 - '@tanstack/router-core': ^1.163.3 + '@tanstack/react-router': ^1.167.2 + '@tanstack/router-core': ^1.167.2 react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' peerDependenciesMeta: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.167.0': - resolution: {integrity: sha512-U7CamtXjuC8ixg1c32Rj/4A2OFBnjtMLdbgbyOGHrFHE7ULWS/yhnZLVXff0QSyn6qF92Oecek9mDMHCaTnB2Q==} + '@tanstack/react-router@1.167.5': + resolution: {integrity: sha512-s1nP6l/7BYZfSwhoNbB7/rUmZ07q/AvkmhBoiDQl3tgy5dpb9Q1qjtIapYdvCOrao1aA/QCaWqxcbGc2Ct1bvQ==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -1600,34 +1596,32 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.163.3': - resolution: {integrity: sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA==} + '@tanstack/router-core@1.167.5': + resolution: {integrity: sha512-8fRgJ0zNJf77R4grCaJQ5Imatjyc4YT5v8rlsPkYYYeUlcFNLbuFRhLlAMdND9gRUMznpnbRDXngpTPgx2K7HQ==} engines: {node: '>=20.19'} + hasBin: true - '@tanstack/router-core@1.167.0': - resolution: {integrity: sha512-pnaaUP+vMQEyL2XjZGe2PXmtzulxvXfGyvEMUs+AEBaNEk77xWA88bl3ujiBRbUxzpK0rxfJf+eSKPdZmBMFdQ==} - engines: {node: '>=20.19'} - - '@tanstack/router-devtools-core@1.163.3': - resolution: {integrity: sha512-FPi64IP0PT1IkoeyGmsD6JoOVOYAb85VCH0mUbSdD90yV0+1UB6oT+D7K27GXkp7SXMJN3mBEjU5rKnNnmSCIw==} + '@tanstack/router-devtools-core@1.166.9': + resolution: {integrity: sha512-PNlA7GmOUX9wY7LUG709Pk3Lg33dfHBztQwzjzrOiOsuf4ggp2R6bwarF8nYGNjG79z/MaB5PN+5yvkCVk8jGw==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/router-core': ^1.163.3 + '@tanstack/router-core': ^1.167.2 csstype: ^3.0.10 peerDependenciesMeta: csstype: optional: true - '@tanstack/router-generator@1.164.0': - resolution: {integrity: sha512-Uiyj+RtW0kdeqEd8NEd3Np1Z2nhJ2xgLS8U+5mTvFrm/s3xkM2LYjJHoLzc6am7sKPDsmeF9a4/NYq3R7ZJP0Q==} + '@tanstack/router-generator@1.166.13': + resolution: {integrity: sha512-ALxSs6OzimiSgpOuIm+AXmc7eUx/oGPwSPpdQbpZ/kX7WHRh6qM7lv8DAN0K3jWcBpzF8eeOIdryWryX8gH+Yg==} engines: {node: '>=20.19'} - '@tanstack/router-plugin@1.164.0': - resolution: {integrity: sha512-cZPsEMhqzyzmuPuDbsTAzBZaT+cj0pGjwdhjxJfPCM06Ax8v4tFR7n/Ug0UCwnNAUEmKZWN3lA9uT+TxXnk9PQ==} + '@tanstack/router-plugin@1.166.14': + resolution: {integrity: sha512-hypyj0qlsAbJf60/glmVYqSVwnRB4hKRrMCUsSXjrPdO2g6gs3z6xHmcWsHQ831C4G9+bSFEK9Uy5EjO3A4THQ==} engines: {node: '>=20.19'} + hasBin: true peerDependencies: '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.163.3 + '@tanstack/react-router': ^1.167.5 vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' vite-plugin-solid: ^2.11.10 webpack: '>=5.92.0' @@ -1643,19 +1637,17 @@ packages: webpack: optional: true - '@tanstack/router-utils@1.161.4': - resolution: {integrity: sha512-r8TpjyIZoqrXXaf2DDyjd44gjGBoyE+/oEaaH68yLI9ySPO1gUWmQENZ1MZnmBnpUGN24NOZxdjDLc8npK0SAw==} + '@tanstack/router-utils@1.161.6': + resolution: {integrity: sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw==} engines: {node: '>=20.19'} - '@tanstack/store@0.9.1': - resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==} - '@tanstack/store@0.9.2': resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==} - '@tanstack/virtual-file-routes@1.161.4': - resolution: {integrity: sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==} + '@tanstack/virtual-file-routes@1.161.7': + resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==} engines: {node: '>=20.19'} + hasBin: true '@trivago/prettier-plugin-sort-imports@6.0.2': resolution: {integrity: sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA==} @@ -1691,8 +1683,8 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/debug@4.1.12': - resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1712,8 +1704,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@24.11.0': - resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} + '@types/node@25.5.0': + resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} @@ -1735,63 +1727,63 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} - '@typescript-eslint/eslint-plugin@8.56.1': - resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} + '@typescript-eslint/eslint-plugin@8.57.1': + resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.56.1 + '@typescript-eslint/parser': ^8.57.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.56.1': - resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} + '@typescript-eslint/parser@8.57.1': + resolution: {integrity: sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.56.1': - resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + '@typescript-eslint/project-service@8.57.1': + resolution: {integrity: sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.56.1': - resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + '@typescript-eslint/scope-manager@8.57.1': + resolution: {integrity: sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.56.1': - resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + '@typescript-eslint/tsconfig-utils@8.57.1': + resolution: {integrity: sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.56.1': - resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} + '@typescript-eslint/type-utils@8.57.1': + resolution: {integrity: sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.56.1': - resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + '@typescript-eslint/types@8.57.1': + resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.56.1': - resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + '@typescript-eslint/typescript-estree@8.57.1': + resolution: {integrity: sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.56.1': - resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + '@typescript-eslint/utils@8.57.1': + resolution: {integrity: sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.56.1': - resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + '@typescript-eslint/visitor-keys@8.57.1': + resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -1883,8 +1875,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.0: - resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + baseline-browser-mapping@2.10.9: + resolution: {integrity: sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==} engines: {node: '>=6.0.0'} hasBin: true @@ -1935,8 +1927,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001775: - resolution: {integrity: sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==} + caniuse-lite@1.0.30001780: + resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2042,8 +2034,8 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} - cosmiconfig@9.0.0: - resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} engines: {node: '>=14'} peerDependencies: typescript: '>=4.9.5' @@ -2139,15 +2131,15 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - eciesjs@0.4.17: - resolution: {integrity: sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w==} + eciesjs@0.4.18: + resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.302: - resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} + electron-to-chromium@1.5.321: + resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2159,8 +2151,8 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - enhanced-resolve@5.20.0: - resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} env-paths@2.2.1: @@ -2182,8 +2174,8 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} hasBin: true @@ -2235,8 +2227,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.3: - resolution: {integrity: sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==} + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -2362,8 +2354,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.4.1: - resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} @@ -2377,8 +2369,8 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} - fs-extra@11.3.3: - resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} engines: {node: '>=14.14'} fsevents@2.3.3: @@ -2392,9 +2384,6 @@ packages: fuzzysort@3.1.0: resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} - fzf@0.5.2: - resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} - gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -2462,8 +2451,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql@16.13.0: - resolution: {integrity: sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA==} + graphql@16.13.1: + resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} has-flag@4.0.0: @@ -2493,8 +2482,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.12.7: - resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} + hono@4.12.8: + resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} engines: {node: '>=16.9.0'} html-parse-stringify@3.0.1: @@ -2522,8 +2511,8 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next@25.8.14: - resolution: {integrity: sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA==} + i18next@25.8.20: + resolution: {integrity: sha512-xjo9+lbX/P1tQt3xpO2rfJiBppNfUnNIPKgCvNsTKsvTOCro1Qr/geXVg1N47j5ScOSaXAPq8ET93raK3Rr06A==} peerDependencies: typescript: ^5 peerDependenciesMeta: @@ -2673,8 +2662,8 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jose@6.1.3: - resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} jotai@2.18.1: resolution: {integrity: sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA==} @@ -2747,74 +2736,74 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-android-arm64@1.31.1: - resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.31.1: - resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.31.1: - resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.31.1: - resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.31.1: - resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.31.1: - resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-arm64-musl@1.31.1: - resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-x64-gnu@1.31.1: - resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-linux-x64-musl@1.31.1: - resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-win32-arm64-msvc@1.31.1: - resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.31.1: - resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.31.1: - resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} lines-and-columns@1.2.4: @@ -3031,8 +3020,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.12.10: - resolution: {integrity: sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==} + msw@2.12.13: + resolution: {integrity: sha512-9CV2mXT9+z0J26MQDfEZZkj/psJ5Er/w0w+t95FWdaGH/DTlhNZBx8vBO5jSYv8AZEnl3ouX+AaTT68KXdAIag==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -3066,8 +3055,8 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -3131,9 +3120,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - package-manager-detector@1.6.0: - resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3206,8 +3192,8 @@ packages: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} powershell-utils@0.1.0: @@ -3477,22 +3463,12 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} - seroval-plugins@1.5.0: - resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} - engines: {node: '>=10'} - peerDependencies: - seroval: ^1.0 - seroval-plugins@1.5.1: resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 - seroval@1.5.0: - resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} - engines: {node: '>=10'} - seroval@1.5.1: resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} engines: {node: '>=10'} @@ -3504,8 +3480,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@4.0.5: - resolution: {integrity: sha512-z0SOHEU1+ADam1UJHrgxJhUsOb0/jBoYc+u9mhWs071KrnORq48X7uCwG3mD2ysQEBtOfeK/MxMGsmzL5Jt+Jg==} + shadcn@4.1.0: + resolution: {integrity: sha512-3zETJ+0Ezj69FS6RL0HOkLKKAR5yXisXx1iISJdfLQfrUqj/VIQlanQi1Ukk+9OE+XHZVj4FQNTBSfbr2CyCYg==} hasBin: true shebang-command@2.0.0: @@ -3634,8 +3610,8 @@ packages: tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - tailwindcss@4.2.1: - resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} @@ -3647,19 +3623,15 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} - tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} - engines: {node: '>=18'} - tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tldts-core@7.0.23: - resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} + tldts-core@7.0.26: + resolution: {integrity: sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==} - tldts@7.0.23: - resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} + tldts@7.0.26: + resolution: {integrity: sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==} hasBin: true to-regex-range@5.0.1: @@ -3670,8 +3642,8 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - tough-cookie@6.0.0: - resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} trim-lines@3.0.1: @@ -3680,8 +3652,8 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -3708,16 +3680,16 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@5.4.4: - resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==} + type-fest@5.5.0: + resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} engines: {node: '>=20'} type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typescript-eslint@8.56.1: - resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} + typescript-eslint@8.57.1: + resolution: {integrity: sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -3728,8 +3700,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} @@ -3979,13 +3951,6 @@ packages: snapshots: - '@antfu/ni@25.0.0': - dependencies: - ansis: 4.2.0 - fzf: 0.5.2 - package-manager-detector: 1.6.0 - tinyexec: 1.0.2 - '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -4000,8 +3965,8 @@ snapshots: '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 @@ -4016,7 +3981,7 @@ snapshots: '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -4100,12 +4065,12 @@ snapshots: '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.28.6': + '@babel/helpers@7.29.2': dependencies: '@babel/template': 7.28.6 '@babel/types': 7.29.0 - '@babel/parser@7.29.0': + '@babel/parser@7.29.2': dependencies: '@babel/types': 7.29.0 @@ -4159,12 +4124,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/types': 7.29.0 '@babel/traverse@7.29.0': @@ -4172,7 +4137,7 @@ snapshots: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/types': 7.29.0 debug: 4.4.3 @@ -4184,11 +4149,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@dotenvx/dotenvx@1.52.0': + '@dotenvx/dotenvx@1.57.0': dependencies: commander: 11.1.0 dotenv: 17.3.1 - eciesjs: 0.4.17 + eciesjs: 0.4.18 execa: 5.1.1 fdir: 6.5.0(picomatch@4.0.3) ignore: 5.3.2 @@ -4200,87 +4165,87 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 - '@esbuild/aix-ppc64@0.27.3': + '@esbuild/aix-ppc64@0.27.4': optional: true - '@esbuild/android-arm64@0.27.3': + '@esbuild/android-arm64@0.27.4': optional: true - '@esbuild/android-arm@0.27.3': + '@esbuild/android-arm@0.27.4': optional: true - '@esbuild/android-x64@0.27.3': + '@esbuild/android-x64@0.27.4': optional: true - '@esbuild/darwin-arm64@0.27.3': + '@esbuild/darwin-arm64@0.27.4': optional: true - '@esbuild/darwin-x64@0.27.3': + '@esbuild/darwin-x64@0.27.4': optional: true - '@esbuild/freebsd-arm64@0.27.3': + '@esbuild/freebsd-arm64@0.27.4': optional: true - '@esbuild/freebsd-x64@0.27.3': + '@esbuild/freebsd-x64@0.27.4': optional: true - '@esbuild/linux-arm64@0.27.3': + '@esbuild/linux-arm64@0.27.4': optional: true - '@esbuild/linux-arm@0.27.3': + '@esbuild/linux-arm@0.27.4': optional: true - '@esbuild/linux-ia32@0.27.3': + '@esbuild/linux-ia32@0.27.4': optional: true - '@esbuild/linux-loong64@0.27.3': + '@esbuild/linux-loong64@0.27.4': optional: true - '@esbuild/linux-mips64el@0.27.3': + '@esbuild/linux-mips64el@0.27.4': optional: true - '@esbuild/linux-ppc64@0.27.3': + '@esbuild/linux-ppc64@0.27.4': optional: true - '@esbuild/linux-riscv64@0.27.3': + '@esbuild/linux-riscv64@0.27.4': optional: true - '@esbuild/linux-s390x@0.27.3': + '@esbuild/linux-s390x@0.27.4': optional: true - '@esbuild/linux-x64@0.27.3': + '@esbuild/linux-x64@0.27.4': optional: true - '@esbuild/netbsd-arm64@0.27.3': + '@esbuild/netbsd-arm64@0.27.4': optional: true - '@esbuild/netbsd-x64@0.27.3': + '@esbuild/netbsd-x64@0.27.4': optional: true - '@esbuild/openbsd-arm64@0.27.3': + '@esbuild/openbsd-arm64@0.27.4': optional: true - '@esbuild/openbsd-x64@0.27.3': + '@esbuild/openbsd-x64@0.27.4': optional: true - '@esbuild/openharmony-arm64@0.27.3': + '@esbuild/openharmony-arm64@0.27.4': optional: true - '@esbuild/sunos-x64@0.27.3': + '@esbuild/sunos-x64@0.27.4': optional: true - '@esbuild/win32-arm64@0.27.3': + '@esbuild/win32-arm64@0.27.4': optional: true - '@esbuild/win32-ia32@0.27.3': + '@esbuild/win32-ia32@0.27.4': optional: true - '@esbuild/win32-x64@0.27.3': + '@esbuild/win32-x64@0.27.4': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.3(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: - eslint: 9.39.3(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -4315,7 +4280,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.39.3': {} + '@eslint/js@9.39.4': {} '@eslint/object-schema@2.1.7': {} @@ -4324,28 +4289,28 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@floating-ui/core@1.7.4': + '@floating-ui/core@1.7.5': dependencies: - '@floating-ui/utils': 0.2.10 + '@floating-ui/utils': 0.2.11 - '@floating-ui/dom@1.7.5': + '@floating-ui/dom@1.7.6': dependencies: - '@floating-ui/core': 1.7.4 - '@floating-ui/utils': 0.2.10 + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@floating-ui/dom': 1.7.5 + '@floating-ui/dom': 1.7.6 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@floating-ui/utils@0.2.10': {} + '@floating-ui/utils@0.2.11': {} '@fontsource-variable/inter@5.2.8': {} - '@hono/node-server@1.19.11(hono@4.12.7)': + '@hono/node-server@1.19.11(hono@4.12.8)': dependencies: - hono: 4.12.7 + hono: 4.12.8 '@humanfs/core@0.19.1': {} @@ -4360,31 +4325,31 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/confirm@5.1.21(@types/node@24.11.0)': + '@inquirer/confirm@5.1.21(@types/node@25.5.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.11.0) - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@25.5.0) + '@inquirer/type': 3.0.10(@types/node@25.5.0) optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 25.5.0 - '@inquirer/core@10.3.2(@types/node@24.11.0)': + '@inquirer/core@10.3.2(@types/node@25.5.0)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/type': 3.0.10(@types/node@25.5.0) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 25.5.0 '@inquirer/figures@1.0.15': {} - '@inquirer/type@3.0.10(@types/node@24.11.0)': + '@inquirer/type@3.0.10(@types/node@25.5.0)': optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 25.5.0 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -4407,7 +4372,7 @@ snapshots: '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.11(hono@4.12.7) + '@hono/node-server': 1.19.11(hono@4.12.8) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -4417,8 +4382,8 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.7 - jose: 6.1.3 + hono: 4.12.8 + jose: 6.2.2 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -4857,7 +4822,7 @@ snapshots: '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -5293,111 +5258,111 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@tabler/icons-react@3.38.0(react@19.2.4)': + '@tabler/icons-react@3.40.0(react@19.2.4)': dependencies: - '@tabler/icons': 3.38.0 + '@tabler/icons': 3.40.0 react: 19.2.4 - '@tabler/icons@3.38.0': {} + '@tabler/icons@3.40.0': {} - '@tailwindcss/node@4.2.1': + '@tailwindcss/node@4.2.2': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.20.0 + enhanced-resolve: 5.20.1 jiti: 2.6.1 - lightningcss: 1.31.1 + lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.1 + tailwindcss: 4.2.2 - '@tailwindcss/oxide-android-arm64@4.2.1': + '@tailwindcss/oxide-android-arm64@4.2.2': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.1': + '@tailwindcss/oxide-darwin-arm64@4.2.2': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.1': + '@tailwindcss/oxide-darwin-x64@4.2.2': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.1': + '@tailwindcss/oxide-freebsd-x64@4.2.2': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.1': + '@tailwindcss/oxide-linux-x64-musl@4.2.2': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.1': + '@tailwindcss/oxide-wasm32-wasi@4.2.2': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': optional: true - '@tailwindcss/oxide@4.2.1': + '@tailwindcss/oxide@4.2.2': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.1 - '@tailwindcss/oxide-darwin-arm64': 4.2.1 - '@tailwindcss/oxide-darwin-x64': 4.2.1 - '@tailwindcss/oxide-freebsd-x64': 4.2.1 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 - '@tailwindcss/oxide-linux-x64-musl': 4.2.1 - '@tailwindcss/oxide-wasm32-wasi': 4.2.1 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - '@tailwindcss/typography@0.5.19(tailwindcss@4.2.1)': + '@tailwindcss/typography@0.5.19(tailwindcss@4.2.2)': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 4.2.1 + tailwindcss: 4.2.2 - '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + '@tailwindcss/vite@4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': dependencies: - '@tailwindcss/node': 4.2.1 - '@tailwindcss/oxide': 4.2.1 - tailwindcss: 4.2.1 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) - '@tanstack/history@1.161.4': {} + '@tanstack/history@1.161.6': {} - '@tanstack/query-core@5.90.20': {} + '@tanstack/query-core@5.91.2': {} - '@tanstack/react-query@5.90.21(react@19.2.4)': + '@tanstack/react-query@5.91.2(react@19.2.4)': dependencies: - '@tanstack/query-core': 5.90.20 + '@tanstack/query-core': 5.91.2 react: 19.2.4 - '@tanstack/react-router-devtools@1.163.3(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.0)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router-devtools@1.166.9(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.5)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/react-router': 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-devtools-core': 1.163.3(@tanstack/router-core@1.167.0)(csstype@3.2.3) + '@tanstack/react-router': 1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-devtools-core': 1.166.9(@tanstack/router-core@1.167.5)(csstype@3.2.3) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) optionalDependencies: - '@tanstack/router-core': 1.167.0 + '@tanstack/router-core': 1.167.5 transitivePeerDependencies: - csstype - '@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/history': 1.161.4 + '@tanstack/history': 1.161.6 '@tanstack/react-store': 0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-core': 1.167.0 + '@tanstack/router-core': 1.167.5 isbot: 5.1.36 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -5411,19 +5376,9 @@ snapshots: react-dom: 19.2.4(react@19.2.4) use-sync-external-store: 1.6.0(react@19.2.4) - '@tanstack/router-core@1.163.3': + '@tanstack/router-core@1.167.5': dependencies: - '@tanstack/history': 1.161.4 - '@tanstack/store': 0.9.1 - cookie-es: 2.0.0 - seroval: 1.5.0 - seroval-plugins: 1.5.0(seroval@1.5.0) - tiny-invariant: 1.3.3 - tiny-warning: 1.0.3 - - '@tanstack/router-core@1.167.0': - dependencies: - '@tanstack/history': 1.161.4 + '@tanstack/history': 1.161.6 '@tanstack/store': 0.9.2 cookie-es: 2.0.0 seroval: 1.5.1 @@ -5431,20 +5386,20 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-devtools-core@1.163.3(@tanstack/router-core@1.167.0)(csstype@3.2.3)': + '@tanstack/router-devtools-core@1.166.9(@tanstack/router-core@1.167.5)(csstype@3.2.3)': dependencies: - '@tanstack/router-core': 1.167.0 + '@tanstack/router-core': 1.167.5 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) tiny-invariant: 1.3.3 optionalDependencies: csstype: 3.2.3 - '@tanstack/router-generator@1.164.0': + '@tanstack/router-generator@1.166.13': dependencies: - '@tanstack/router-core': 1.163.3 - '@tanstack/router-utils': 1.161.4 - '@tanstack/virtual-file-routes': 1.161.4 + '@tanstack/router-core': 1.167.5 + '@tanstack/router-utils': 1.161.6 + '@tanstack/virtual-file-routes': 1.161.7 prettier: 3.8.1 recast: 0.23.11 source-map: 0.7.6 @@ -5453,7 +5408,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + '@tanstack/router-plugin@1.166.14(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -5461,24 +5416,24 @@ snapshots: '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 - '@tanstack/router-core': 1.163.3 - '@tanstack/router-generator': 1.164.0 - '@tanstack/router-utils': 1.161.4 - '@tanstack/virtual-file-routes': 1.161.4 + '@tanstack/router-core': 1.167.5 + '@tanstack/router-generator': 1.166.13 + '@tanstack/router-utils': 1.161.6 + '@tanstack/virtual-file-routes': 1.161.7 chokidar: 3.6.0 unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + '@tanstack/react-router': 1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) transitivePeerDependencies: - supports-color - '@tanstack/router-utils@1.161.4': + '@tanstack/router-utils@1.161.6': dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.1 - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/types': 7.29.0 ansis: 4.2.0 babel-dead-code-elimination: 1.0.12 @@ -5488,16 +5443,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/store@0.9.1': {} - '@tanstack/store@0.9.2': {} - '@tanstack/virtual-file-routes@1.161.4': {} + '@tanstack/virtual-file-routes@1.161.7': {} '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)': dependencies: '@babel/generator': 7.29.1 - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 javascript-natural-sort: 0.7.1 @@ -5516,7 +5469,7 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 @@ -5528,14 +5481,14 @@ snapshots: '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/types': 7.29.0 '@types/babel__traverse@7.28.0': dependencies: '@babel/types': 7.29.0 - '@types/debug@4.1.12': + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -5557,9 +5510,9 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@24.11.0': + '@types/node@25.5.0': dependencies: - undici-types: 7.16.0 + undici-types: 7.18.2 '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: @@ -5577,100 +5530,100 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.1 - eslint: 9.39.3(jiti@2.6.1) + '@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/type-utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.1 + eslint: 9.39.4(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.1 debug: 4.4.3 - eslint: 9.39.3(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.57.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) + '@typescript-eslint/types': 8.57.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.56.1': + '@typescript-eslint/scope-manager@8.57.1': dependencies: - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/visitor-keys': 8.57.1 - '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.3(jiti@2.6.1) - ts-api-utils: 2.4.0(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.56.1': {} + '@typescript-eslint/types@8.57.1': {} - '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/project-service': 8.57.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/visitor-keys': 8.57.1 debug: 4.4.3 minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - eslint: 9.39.3(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.56.1': + '@typescript-eslint/visitor-keys@8.57.1': dependencies: - '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/types': 8.57.1 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -5678,7 +5631,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -5743,7 +5696,7 @@ snapshots: babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.29.0 - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 transitivePeerDependencies: @@ -5755,7 +5708,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.0: {} + baseline-browser-mapping@2.10.9: {} binary-extensions@2.3.0: {} @@ -5792,10 +5745,10 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001775 - electron-to-chromium: 1.5.302 - node-releases: 2.0.27 + baseline-browser-mapping: 2.10.9 + caniuse-lite: 1.0.30001780 + electron-to-chromium: 1.5.321 + node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) bundle-name@4.1.0: @@ -5816,7 +5769,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001775: {} + caniuse-lite@1.0.30001780: {} ccount@2.0.1: {} @@ -5902,7 +5855,7 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - cosmiconfig@9.0.0(typescript@5.9.3): + cosmiconfig@9.0.1(typescript@5.9.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 @@ -5970,7 +5923,7 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - eciesjs@0.4.17: + eciesjs@0.4.18: dependencies: '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0) '@noble/ciphers': 1.3.0 @@ -5979,7 +5932,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.302: {} + electron-to-chromium@1.5.321: {} emoji-regex@10.6.0: {} @@ -5987,7 +5940,7 @@ snapshots: encodeurl@2.0.0: {} - enhanced-resolve@5.20.0: + enhanced-resolve@5.20.1: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 @@ -6006,34 +5959,34 @@ snapshots: dependencies: es-errors: 1.3.0 - esbuild@0.27.3: + esbuild@0.27.4: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 escalade@3.2.0: {} @@ -6043,24 +5996,24 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.3(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)): dependencies: - eslint: 9.39.3(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) - eslint-plugin-react-hooks@7.0.1(eslint@9.39.3(jiti@2.6.1)): + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): dependencies: '@babel/core': 7.29.0 - '@babel/parser': 7.29.0 - eslint: 9.39.3(jiti@2.6.1) + '@babel/parser': 7.29.2 + eslint: 9.39.4(jiti@2.6.1) hermes-parser: 0.25.1 zod: 4.3.6 zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.4.26(eslint@9.39.3(jiti@2.6.1)): + eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@2.6.1)): dependencies: - eslint: 9.39.3(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) eslint-scope@8.4.0: dependencies: @@ -6073,15 +6026,15 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.3(jiti@2.6.1): + eslint@9.39.4(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.3 + '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 @@ -6270,10 +6223,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.1 + flatted: 3.4.2 keyv: 4.5.4 - flatted@3.4.1: {} + flatted@3.4.2: {} formdata-polyfill@4.0.10: dependencies: @@ -6283,7 +6236,7 @@ snapshots: fresh@2.0.0: {} - fs-extra@11.3.3: + fs-extra@11.3.4: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.0 @@ -6296,8 +6249,6 @@ snapshots: fuzzysort@3.1.0: {} - fzf@0.5.2: {} - gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -6357,7 +6308,7 @@ snapshots: graceful-fs@4.2.11: {} - graphql@16.13.0: {} + graphql@16.13.1: {} has-flag@4.0.0: {} @@ -6399,7 +6350,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.12.7: {} + hono@4.12.8: {} html-parse-stringify@3.0.1: dependencies: @@ -6428,11 +6379,11 @@ snapshots: i18next-browser-languagedetector@8.2.1: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 - i18next@25.8.14(typescript@5.9.3): + i18next@25.8.20(typescript@5.9.3): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 optionalDependencies: typescript: 5.9.3 @@ -6528,7 +6479,7 @@ snapshots: jiti@2.6.1: {} - jose@6.1.3: {} + jose@6.2.2: {} jotai@2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4): optionalDependencies: @@ -6578,54 +6529,54 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-android-arm64@1.31.1: + lightningcss-android-arm64@1.32.0: optional: true - lightningcss-darwin-arm64@1.31.1: + lightningcss-darwin-arm64@1.32.0: optional: true - lightningcss-darwin-x64@1.31.1: + lightningcss-darwin-x64@1.32.0: optional: true - lightningcss-freebsd-x64@1.31.1: + lightningcss-freebsd-x64@1.32.0: optional: true - lightningcss-linux-arm-gnueabihf@1.31.1: + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true - lightningcss-linux-arm64-gnu@1.31.1: + lightningcss-linux-arm64-gnu@1.32.0: optional: true - lightningcss-linux-arm64-musl@1.31.1: + lightningcss-linux-arm64-musl@1.32.0: optional: true - lightningcss-linux-x64-gnu@1.31.1: + lightningcss-linux-x64-gnu@1.32.0: optional: true - lightningcss-linux-x64-musl@1.31.1: + lightningcss-linux-x64-musl@1.32.0: optional: true - lightningcss-win32-arm64-msvc@1.31.1: + lightningcss-win32-arm64-msvc@1.32.0: optional: true - lightningcss-win32-x64-msvc@1.31.1: + lightningcss-win32-x64-msvc@1.32.0: optional: true - lightningcss@1.31.1: + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.31.1 - lightningcss-darwin-arm64: 1.31.1 - lightningcss-darwin-x64: 1.31.1 - lightningcss-freebsd-x64: 1.31.1 - lightningcss-linux-arm-gnueabihf: 1.31.1 - lightningcss-linux-arm64-gnu: 1.31.1 - lightningcss-linux-arm64-musl: 1.31.1 - lightningcss-linux-x64-gnu: 1.31.1 - lightningcss-linux-x64-musl: 1.31.1 - lightningcss-win32-arm64-msvc: 1.31.1 - lightningcss-win32-x64-msvc: 1.31.1 + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 lines-and-columns@1.2.4: {} @@ -6988,7 +6939,7 @@ snapshots: micromark@4.0.2: dependencies: - '@types/debug': 4.1.12 + '@types/debug': 4.1.13 debug: 4.4.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 @@ -7039,14 +6990,14 @@ snapshots: ms@2.1.3: {} - msw@2.12.10(@types/node@24.11.0)(typescript@5.9.3): + msw@2.12.13(@types/node@25.5.0)(typescript@5.9.3): dependencies: - '@inquirer/confirm': 5.1.21(@types/node@24.11.0) + '@inquirer/confirm': 5.1.21(@types/node@25.5.0) '@mswjs/interceptors': 0.41.3 '@open-draft/deferred-promise': 2.2.0 '@types/statuses': 2.0.6 cookie: 1.1.1 - graphql: 16.13.0 + graphql: 16.13.1 headers-polyfill: 4.0.3 is-node-process: 1.2.0 outvariant: 1.4.3 @@ -7055,8 +7006,8 @@ snapshots: rettime: 0.10.1 statuses: 2.0.2 strict-event-emitter: 0.5.1 - tough-cookie: 6.0.0 - type-fest: 5.4.4 + tough-cookie: 6.0.1 + type-fest: 5.5.0 until-async: 3.0.2 yargs: 17.7.2 optionalDependencies: @@ -7080,7 +7031,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-releases@2.0.27: {} + node-releases@2.0.36: {} normalize-path@3.0.0: {} @@ -7155,8 +7106,6 @@ snapshots: dependencies: p-limit: 3.1.0 - package-manager-detector@1.6.0: {} - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7220,7 +7169,7 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.5.6: + postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -7339,11 +7288,11 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 - react-i18next@16.5.8(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + react-i18next@16.5.8(i18next@25.8.20(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 - i18next: 25.8.14(typescript@5.9.3) + i18next: 25.8.20(typescript@5.9.3) react: 19.2.4 use-sync-external-store: 1.6.0(react@19.2.4) optionalDependencies: @@ -7399,7 +7348,7 @@ snapshots: react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.4): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 react: 19.2.4 use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4) use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4) @@ -7542,16 +7491,10 @@ snapshots: transitivePeerDependencies: - supports-color - seroval-plugins@1.5.0(seroval@1.5.0): - dependencies: - seroval: 1.5.0 - seroval-plugins@1.5.1(seroval@1.5.1): dependencies: seroval: 1.5.1 - seroval@1.5.0: {} - seroval@1.5.1: {} serve-static@2.2.1: @@ -7565,33 +7508,32 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@4.0.5(@types/node@24.11.0)(typescript@5.9.3): + shadcn@4.1.0(@types/node@25.5.0)(typescript@5.9.3): dependencies: - '@antfu/ni': 25.0.0 '@babel/core': 7.29.0 - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@dotenvx/dotenvx': 1.52.0 + '@dotenvx/dotenvx': 1.57.0 '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.1 commander: 14.0.3 - cosmiconfig: 9.0.0(typescript@5.9.3) + cosmiconfig: 9.0.1(typescript@5.9.3) dedent: 1.7.2 deepmerge: 4.3.1 diff: 8.0.3 execa: 9.6.1 fast-glob: 3.3.3 - fs-extra: 11.3.3 + fs-extra: 11.3.4 fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 - msw: 2.12.10(@types/node@24.11.0)(typescript@5.9.3) + msw: 2.12.13(@types/node@25.5.0)(typescript@5.9.3) node-fetch: 3.3.2 open: 11.0.0 ora: 8.2.0 - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 prompts: 2.4.2 recast: 0.23.11 @@ -7728,7 +7670,7 @@ snapshots: tailwind-merge@3.5.0: {} - tailwindcss@4.2.1: {} + tailwindcss@4.2.2: {} tapable@2.3.0: {} @@ -7736,18 +7678,16 @@ snapshots: tiny-warning@1.0.3: {} - tinyexec@1.0.2: {} - tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tldts-core@7.0.23: {} + tldts-core@7.0.26: {} - tldts@7.0.23: + tldts@7.0.26: dependencies: - tldts-core: 7.0.23 + tldts-core: 7.0.26 to-regex-range@5.0.1: dependencies: @@ -7755,15 +7695,15 @@ snapshots: toidentifier@1.0.1: {} - tough-cookie@6.0.0: + tough-cookie@6.0.1: dependencies: - tldts: 7.0.23 + tldts: 7.0.26 trim-lines@3.0.1: {} trough@2.2.0: {} - ts-api-utils@2.4.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -7782,7 +7722,7 @@ snapshots: tsx@4.21.0: dependencies: - esbuild: 0.27.3 + esbuild: 0.27.4 get-tsconfig: 4.13.6 optionalDependencies: fsevents: 2.3.3 @@ -7793,7 +7733,7 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@5.4.4: + type-fest@5.5.0: dependencies: tagged-tag: 1.0.0 @@ -7803,20 +7743,20 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typescript-eslint@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.3(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color typescript@5.9.3: {} - undici-types@7.16.0: {} + undici-types@7.18.2: {} unicorn-magic@0.3.0: {} @@ -7930,19 +7870,19 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0): + vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0): dependencies: - esbuild: 0.27.3 + esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - postcss: 8.5.6 + postcss: 8.5.8 rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 25.5.0 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.31.1 + lightningcss: 1.32.0 tsx: 4.21.0 void-elements@3.1.0: {}