Merge branch 'sipeed:main' into main

This commit is contained in:
Harmoon 2026-04-01 19:42:39 +08:00 committed by GitHub
commit 50f2f6f987
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
103 changed files with 9170 additions and 1057 deletions

View file

@ -61,6 +61,9 @@ linters:
- usestdlibvars - usestdlibvars
- usetesting - usetesting
settings: settings:
gomoddirectives:
replace-allow-list:
- github.com/bwmarrin/discordgo
errcheck: errcheck:
check-type-assertions: true check-type-assertions: true
check-blank: true check-blank: true

View file

@ -57,6 +57,8 @@
## 📢 Actualités ## 📢 Actualités
2026-03-31 📱 **Support Android !** PicoClaw fonctionne maintenant sur Android ! Téléchargez l'APK sur [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 publiée !** Refonte de l'architecture Agent (SubTurn, Hooks, Steering, EventBus), intégration WeChat/WeCom, renforcement de la sécurité (.security.yml, filtrage des données sensibles), nouveaux providers (AWS Bedrock, Azure, Xiaomi MiMo), et 35 corrections de bugs. PicoClaw a atteint **26K Stars** ! 2026-03-25 🚀 **v0.2.4 publiée !** Refonte de l'architecture Agent (SubTurn, Hooks, Steering, EventBus), intégration WeChat/WeCom, renforcement de la sécurité (.security.yml, filtrage des données sensibles), nouveaux providers (AWS Bedrock, Azure, Xiaomi MiMo), et 35 corrections de bugs. PicoClaw a atteint **26K Stars** !
2026-03-17 🚀 **v0.2.3 publiée !** Interface system tray (Windows & Linux), requête de statut des sous-agents (`spawn_status`), rechargement à chaud expérimental du Gateway, sécurisation Cron, et 2 correctifs de sécurité. PicoClaw a atteint **25K Stars** ! 2026-03-17 🚀 **v0.2.3 publiée !** Interface system tray (Windows & Linux), requête de statut des sous-agents (`spawn_status`), rechargement à chaud expérimental du Gateway, sécurisation Cron, et 2 correctifs de sécurité. PicoClaw a atteint **25K Stars** !
@ -321,9 +323,9 @@ Suivez ensuite la section Terminal Launcher ci-dessous pour terminer la configur
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512"> <img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
**Option 2 : Installation APK (bientôt disponible)** **Option 2 : Installation APK**
Un APK Android autonome avec WebUI intégré est en développement. Restez à l'écoute ! Téléchargez l'APK depuis [picoclaw.io](https://picoclaw.io/download/) et installez-le directement. Pas besoin de Termux !
<details> <details>
<summary><b>Terminal Launcher (pour les environnements à ressources limitées)</b></summary> <summary><b>Terminal Launcher (pour les environnements à ressources limitées)</b></summary>

View file

@ -56,6 +56,8 @@
## 📢 Berita ## 📢 Berita
2026-03-31 📱 **Dukungan Android!** PicoClaw sekarang berjalan di Android! Unduh APK di [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 Dirilis!** Perombakan arsitektur Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keamanan (.security.yml, penyaringan data sensitif), provider baru (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 perbaikan bug. PicoClaw telah mencapai **26K Stars**! 2026-03-25 🚀 **v0.2.4 Dirilis!** Perombakan arsitektur Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keamanan (.security.yml, penyaringan data sensitif), provider baru (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 perbaikan bug. PicoClaw telah mencapai **26K Stars**!
2026-03-17 🚀 **v0.2.3 Dirilis!** UI system tray (Windows & Linux), pelacakan status sub-agent (`spawn_status`), eksperimental Gateway hot-reload, gerbang keamanan Cron, dan 2 perbaikan keamanan. PicoClaw telah mencapai **25K Stars**! 2026-03-17 🚀 **v0.2.3 Dirilis!** UI system tray (Windows & Linux), pelacakan status sub-agent (`spawn_status`), eksperimental Gateway hot-reload, gerbang keamanan Cron, dan 2 perbaikan keamanan. PicoClaw telah mencapai **25K Stars**!
@ -318,9 +320,9 @@ Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512"> <img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
**Opsi 2: Instal APK (segera hadir)** **Opsi 2: Instal APK**
APK Android mandiri dengan WebUI bawaan sedang dalam pengembangan. Pantau terus! Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux!
<details> <details>
<summary><b>Terminal Launcher (untuk lingkungan dengan sumber daya terbatas)</b></summary> <summary><b>Terminal Launcher (untuk lingkungan dengan sumber daya terbatas)</b></summary>

View file

@ -56,6 +56,8 @@
## 📢 Novità ## 📢 Novità
2026-03-31 📱 **Supporto Android!** PicoClaw ora funziona su Android! Scarica l'APK su [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 rilasciata!** Revisione dell'architettura Agent (SubTurn, Hooks, Steering, EventBus), integrazione WeChat/WeCom, rafforzamento della sicurezza (.security.yml, filtraggio dati sensibili), nuovi provider (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correzioni di bug. PicoClaw raggiunge **26K Stars**! 2026-03-25 🚀 **v0.2.4 rilasciata!** Revisione dell'architettura Agent (SubTurn, Hooks, Steering, EventBus), integrazione WeChat/WeCom, rafforzamento della sicurezza (.security.yml, filtraggio dati sensibili), nuovi provider (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correzioni di bug. PicoClaw raggiunge **26K Stars**!
2026-03-17 🚀 **v0.2.3 rilasciata!** Interfaccia system tray (Windows & Linux), query sullo stato dei sub-agent (`spawn_status`), hot-reload sperimentale del Gateway, gate di sicurezza per Cron e 2 correzioni di sicurezza. PicoClaw raggiunge **25K Stars**! 2026-03-17 🚀 **v0.2.3 rilasciata!** Interfaccia system tray (Windows & Linux), query sullo stato dei sub-agent (`spawn_status`), hot-reload sperimentale del Gateway, gate di sicurezza per Cron e 2 correzioni di sicurezza. PicoClaw raggiunge **25K Stars**!
@ -318,9 +320,9 @@ Poi segui la sezione Terminal Launcher qui sotto per completare la configurazion
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512"> <img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
**Opzione 2: APK Install (prossimamente)** **Opzione 2: Installazione APK**
Un APK Android standalone con WebUI integrato è in sviluppo. Resta sintonizzato! Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux!
<details> <details>
<summary><b>Terminal Launcher (per ambienti con risorse limitate)</b></summary> <summary><b>Terminal Launcher (per ambienti con risorse limitate)</b></summary>

View file

@ -56,6 +56,8 @@
## 📢 ニュース ## 📢 ニュース
2026-03-31 📱 **Android サポート!** PicoClawがAndroidで動作APKは[picoclaw.io](https://picoclaw.io/download)からダウンロード
2026-03-25 🚀 **v0.2.4 リリース!** Agent アーキテクチャ全面刷新SubTurn、Hooks、Steering、EventBus、WeChat/WeCom 統合、セキュリティ強化(.security.yml、機密データフィルタリング、新プロバイダーAWS Bedrock、Azure、Xiaomi MiMo、35 件のバグ修正。PicoClaw **26K ⭐** 達成! 2026-03-25 🚀 **v0.2.4 リリース!** Agent アーキテクチャ全面刷新SubTurn、Hooks、Steering、EventBus、WeChat/WeCom 統合、セキュリティ強化(.security.yml、機密データフィルタリング、新プロバイダーAWS Bedrock、Azure、Xiaomi MiMo、35 件のバグ修正。PicoClaw **26K ⭐** 達成!
2026-03-17 🚀 **v0.2.3 リリース!** システムトレイ UIWindows & Linux、サブエージェントステータス追跡`spawn_status`)、実験的 Gateway ホットリロード、cron セキュリティゲート、セキュリティ修正 2 件。PicoClaw **25K ⭐** 達成! 2026-03-17 🚀 **v0.2.3 リリース!** システムトレイ UIWindows & Linux、サブエージェントステータス追跡`spawn_status`)、実験的 Gateway ホットリロード、cron セキュリティゲート、セキュリティ修正 2 件。PicoClaw **25K ⭐** 達成!
@ -318,9 +320,9 @@ termux-chroot ./picoclaw onboard # chroot で標準的な Linux ファイル
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512"> <img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
**オプション 2: APK インストール(近日公開)** **オプション 2: APK インストール**
内蔵 WebUI を備えたスタンドアロン Android APK を開発中です。お楽しみに [picoclaw.io](https://picoclaw.io/download/) から APK をダウンロードして直接インストール。Termux 不要
<details> <details>
<summary><b>Terminal Launcherリソース制約環境向け</b></summary> <summary><b>Terminal Launcherリソース制約環境向け</b></summary>

View file

@ -56,6 +56,8 @@
## 📢 News ## 📢 News
2026-03-31 📱 **Android Support!** PicoClaw now runs on Android! Download the APK at [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 Released!** Agent architecture overhaul (SubTurn, Hooks, Steering, EventBus), WeChat/WeCom integration, security hardening (.security.yml, sensitive data filtering), new providers (AWS Bedrock, Azure, Xiaomi MiMo), and 35 bug fixes. PicoClaw has reached **26K Stars**! 2026-03-25 🚀 **v0.2.4 Released!** Agent architecture overhaul (SubTurn, Hooks, Steering, EventBus), WeChat/WeCom integration, security hardening (.security.yml, sensitive data filtering), new providers (AWS Bedrock, Azure, Xiaomi MiMo), and 35 bug fixes. PicoClaw has reached **26K Stars**!
2026-03-17 🚀 **v0.2.3 Released!** System tray UI (Windows & Linux), sub-agent status query (`spawn_status`), experimental Gateway hot-reload, Cron security gating, and 2 security fixes. PicoClaw has reached **25K Stars**! 2026-03-17 🚀 **v0.2.3 Released!** System tray UI (Windows & Linux), sub-agent status query (`spawn_status`), experimental Gateway hot-reload, Cron security gating, and 2 security fixes. PicoClaw has reached **25K Stars**!
@ -318,9 +320,9 @@ Then follow the Terminal Launcher section below to complete configuration.
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512"> <img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
**Option 2: APK Install (coming soon)** **Option 2: APK Install**
A standalone Android APK with built-in WebUI is in development. Stay tuned! Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required!
<details> <details>
<summary><b>Terminal Launcher (for resource-constrained environments)</b></summary> <summary><b>Terminal Launcher (for resource-constrained environments)</b></summary>

View file

@ -56,6 +56,8 @@
## 📢 Berita ## 📢 Berita
2026-03-31 📱 **Sokongan Android!** PicoClaw sekarang berjalan di Android! Muat turun APK di [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 Dikeluarkan!** Penstrukturan semula seni bina Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keselamatan (.security.yml, penapisan data sensitif), penyedia baharu (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 pembetulan pepijat. PicoClaw mencapai **26K Stars**! 2026-03-25 🚀 **v0.2.4 Dikeluarkan!** Penstrukturan semula seni bina Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keselamatan (.security.yml, penapisan data sensitif), penyedia baharu (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 pembetulan pepijat. PicoClaw mencapai **26K Stars**!
2026-03-17 🚀 **v0.2.3 Dikeluarkan!** UI dulang sistem (Windows & Linux), pertanyaan status sub-agent (`spawn_status`), muat semula panas Gateway eksperimental, kawalan keselamatan Cron, dan 2 pembetulan keselamatan. PicoClaw mencapai **25K Stars**! 2026-03-17 🚀 **v0.2.3 Dikeluarkan!** UI dulang sistem (Windows & Linux), pertanyaan status sub-agent (`spawn_status`), muat semula panas Gateway eksperimental, kawalan keselamatan Cron, dan 2 pembetulan keselamatan. PicoClaw mencapai **25K Stars**!
@ -315,9 +317,9 @@ Kemudian ikuti bahagian Pelancar Terminal di bawah untuk melengkapkan konfiguras
<img src="assets/termux.jpg" alt="PicoClaw pada Termux" width="512"> <img src="assets/termux.jpg" alt="PicoClaw pada Termux" width="512">
**Pilihan 2: APK (akan datang)** **Pilihan 2: Pasang APK**
APK Android bebas dengan WebUI terbina dalam sedang dalam pembangunan. Nantikan! Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan!
<details> <details>
<summary><b>Pelancar Terminal (untuk persekitaran terhad sumber)</b></summary> <summary><b>Pelancar Terminal (untuk persekitaran terhad sumber)</b></summary>

View file

@ -56,6 +56,8 @@
## 📢 Novidades ## 📢 Novidades
2026-03-31 📱 **Suporte Android!** PicoClaw agora roda no Android! Baixe o APK em [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 Lançada!** Reformulação da arquitetura Agent (SubTurn, Hooks, Steering, EventBus), integração WeChat/WeCom, fortalecimento de segurança (.security.yml, filtragem de dados sensíveis), novos providers (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correções de bugs. O PicoClaw atingiu **26K Stars**! 2026-03-25 🚀 **v0.2.4 Lançada!** Reformulação da arquitetura Agent (SubTurn, Hooks, Steering, EventBus), integração WeChat/WeCom, fortalecimento de segurança (.security.yml, filtragem de dados sensíveis), novos providers (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correções de bugs. O PicoClaw atingiu **26K Stars**!
2026-03-17 🚀 **v0.2.3 Lançada!** UI na bandeja do sistema (Windows e Linux), consulta de status de sub-agent (`spawn_status`), hot-reload experimental do Gateway, controle de segurança do Cron e 2 correções de segurança. O PicoClaw atingiu **25K Stars**! 2026-03-17 🚀 **v0.2.3 Lançada!** UI na bandeja do sistema (Windows e Linux), consulta de status de sub-agent (`spawn_status`), hot-reload experimental do Gateway, controle de segurança do Cron e 2 correções de segurança. O PicoClaw atingiu **25K Stars**!
@ -318,9 +320,9 @@ Em seguida, siga a seção Terminal Launcher abaixo para concluir a configuraç
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512"> <img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
**Opção 2: Instalação via APK (em breve)** **Opção 2: Instalação via APK**
Um APK Android independente com WebUI integrado está em desenvolvimento. Fique ligado! Baixe o APK de [picoclaw.io](https://picoclaw.io/download/) e instale diretamente. Sem necessidade de Termux!
<details> <details>
<summary><b>Terminal Launcher (para ambientes com recursos limitados)</b></summary> <summary><b>Terminal Launcher (para ambientes com recursos limitados)</b></summary>

View file

@ -56,6 +56,8 @@
## 📢 Tin tức ## 📢 Tin tức
2026-03-31 📱 **Hỗ trợ Android!** PicoClaw giờ chạy trên Android! Tải APK tại [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 đã phát hành!** Tái cấu trúc kiến trúc Agent (SubTurn, Hooks, Steering, EventBus), tích hợp WeChat/WeCom, tăng cường bảo mật (.security.yml, lọc dữ liệu nhạy cảm), provider mới (AWS Bedrock, Azure, Xiaomi MiMo) và 35 bản vá lỗi. PicoClaw đã đạt **26K Stars**! 2026-03-25 🚀 **v0.2.4 đã phát hành!** Tái cấu trúc kiến trúc Agent (SubTurn, Hooks, Steering, EventBus), tích hợp WeChat/WeCom, tăng cường bảo mật (.security.yml, lọc dữ liệu nhạy cảm), provider mới (AWS Bedrock, Azure, Xiaomi MiMo) và 35 bản vá lỗi. PicoClaw đã đạt **26K Stars**!
2026-03-17 🚀 **v0.2.3 đã phát hành!** Giao diện system tray (Windows & Linux), truy vấn trạng thái sub-agent (`spawn_status`), thử nghiệm Gateway hot-reload, bảo mật Cron, và 2 bản vá bảo mật. PicoClaw đã đạt **25K Stars**! 2026-03-17 🚀 **v0.2.3 đã phát hành!** Giao diện system tray (Windows & Linux), truy vấn trạng thái sub-agent (`spawn_status`), thử nghiệm Gateway hot-reload, bảo mật Cron, và 2 bản vá bảo mật. PicoClaw đã đạt **25K Stars**!
@ -318,9 +320,9 @@ Sau đó làm theo phần Terminal Launcher bên dưới để hoàn tất cấu
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512"> <img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
**Tùy chọn 2: Cài đặt APK (sắp ra mắt)** **Tùy chọn 2: Cài đặt APK**
Một APK Android độc lập với WebUI tích hợp đang được phát triển. Hãy đón chờ! Tải APK từ [picoclaw.io](https://picoclaw.io/download/) và cài đặt trực tiếp. Không cần Termux!
<details> <details>
<summary><b>Terminal Launcher (cho môi trường hạn chế tài nguyên)</b></summary> <summary><b>Terminal Launcher (cho môi trường hạn chế tài nguyên)</b></summary>

View file

@ -56,6 +56,8 @@
## 📢 新闻 ## 📢 新闻
2026-03-31 📱 **Android 支持!** PicoClaw 现可在 Android 上运行APK 下载地址:[picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 发布!** Agent 架构全面重构SubTurn、Hook、Steering、EventBus、微信/企业微信深度集成、安全体系升级(.security.yml、敏感数据过滤、新增 ProviderAWS Bedrock、Azure、小米 MiMo以及 35 项 Bug 修复。PicoClaw 已达 **26K ⭐** 2026-03-25 🚀 **v0.2.4 发布!** Agent 架构全面重构SubTurn、Hook、Steering、EventBus、微信/企业微信深度集成、安全体系升级(.security.yml、敏感数据过滤、新增 ProviderAWS Bedrock、Azure、小米 MiMo以及 35 项 Bug 修复。PicoClaw 已达 **26K ⭐**
2026-03-17 🚀 **v0.2.3 发布!** 系统托盘 UIWindows & Linux、子 Agent 状态查询 (`spawn_status`)、实验性 Gateway 热重载、Cron 安全门控,以及 2 项安全修复。PicoClaw 已达 **25K ⭐** 2026-03-17 🚀 **v0.2.3 发布!** 系统托盘 UIWindows & Linux、子 Agent 状态查询 (`spawn_status`)、实验性 Gateway 热重载、Cron 安全门控,以及 2 项安全修复。PicoClaw 已达 **25K ⭐**
@ -318,9 +320,9 @@ termux-chroot ./picoclaw onboard # chroot 提供标准 Linux 文件系统布
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512"> <img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
**方式二APK 安装(即将推出)** **方式二APK 安装**
内置 WebUI 的独立 Android APK 正在开发中,敬请期待 从 [picoclaw.io](https://picoclaw.io/download/) 下载 APK 并直接安装,无需 Termux
<details> <details>
<summary><b>Terminal Launcher适用于资源受限环境</b></summary> <summary><b>Terminal Launcher适用于资源受限环境</b></summary>

View file

@ -418,6 +418,9 @@
"read_file": { "read_file": {
"enabled": true "enabled": true
}, },
"send_tts": {
"enabled": false
},
"spawn": { "spawn": {
"enabled": true "enabled": true
}, },

View file

@ -13,18 +13,20 @@ Le canal Telegram utilise le long polling via l'API Bot Telegram pour une commun
"enabled": true, "enabled": true,
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
"allow_from": ["123456789"], "allow_from": ["123456789"],
"proxy": "" "proxy": "",
"use_markdown_v2": false
} }
} }
} }
``` ```
| Champ | Type | Requis | Description | | Champ | Type | Requis | Description |
| ---------- | ------ | ------ | ------------------------------------------------------------------------ | | --------------- | ------ | ------ | ------------------------------------------------------------------------ |
| enabled | bool | Oui | Activer ou non le canal Telegram | | enabled | bool | Oui | Activer ou non le canal Telegram |
| token | string | Oui | Token de l'API Bot Telegram | | token | string | Oui | Token de l'API Bot Telegram |
| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs | | 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) | | proxy | string | Non | URL du proxy pour se connecter à l'API Telegram (ex. http://127.0.0.1:7890) |
| use_markdown_v2 | bool | Non | Activer le formatage Telegram MarkdownV2 |
## Configuration initiale ## Configuration initiale
@ -33,3 +35,20 @@ Le canal Telegram utilise le long polling via l'API Bot Telegram pour une commun
3. Obtenir le Token de l'API HTTP 3. Obtenir le Token de l'API HTTP
4. Renseigner le Token dans le fichier de configuration 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`) 5. (Optionnel) Configurer `allow_from` pour restreindre les identifiants utilisateur autorisés à interagir (les IDs peuvent être obtenus via `@userinfobot`)
## Formatage avancées
Vous pouvez définir `use_markdown_v2: true` pour activer les options de formatage améliorées. Cela permet au bot d'utiliser toutes les fonctionnalités de Telegram MarkdownV2, y compris les styles imbriqués, les spoilers et les blocs de largeur fixe personnalisés.
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"use_markdown_v2": true
}
}
}
```

View file

@ -13,18 +13,20 @@ Telegram チャンネルは、Telegram Bot API を使用したロングポーリ
"enabled": true, "enabled": true,
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
"allow_from": ["123456789"], "allow_from": ["123456789"],
"proxy": "" "proxy": "",
"use_markdown_v2": false
} }
} }
} }
``` ```
| フィールド | 型 | 必須 | 説明 | | フィールド | 型 | 必須 | 説明 |
| ---------- | ------ | ---- | ----------------------------------------------------------------- | | --------------- | ------ | ---- | ----------------------------------------------------------------- |
| enabled | bool | はい | Telegram チャンネルを有効にするかどうか | | enabled | bool | はい | Telegram チャンネルを有効にするかどうか |
| token | string | はい | Telegram Bot API トークン | | token | string | はい | Telegram Bot API トークン |
| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 | | allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 |
| proxy | string | いいえ | Telegram API への接続に使用するプロキシ URL (例: http://127.0.0.1:7890) | | proxy | string | いいえ | Telegram API への接続に使用するプロキシ URL (例: http://127.0.0.1:7890) |
| use_markdown_v2 | bool | いいえ | Telegram MarkdownV2 フォーマットを有効にする |
## セットアップ手順 ## セットアップ手順
@ -33,3 +35,20 @@ Telegram チャンネルは、Telegram Bot API を使用したロングポーリ
3. HTTP API トークンを取得する 3. HTTP API トークンを取得する
4. 設定ファイルにトークンを入力する 4. 設定ファイルにトークンを入力する
5. (任意) `allow_from` を設定して、対話を許可するユーザー ID を制限するID は `@userinfobot` で取得可能) 5. (任意) `allow_from` を設定して、対話を許可するユーザー ID を制限するID は `@userinfobot` で取得可能)
## 高度なフォーマット
`use_markdown_v2: true` を設定することで、增强されたフォーマットオプションを有効にできます。これにより、ボットは Telegram MarkdownV2 の全機能(ネストされたスタイル、スポイラー、カスタム固定幅ブロックなど)を利用できます。
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"use_markdown_v2": true
}
}
}
```

View file

@ -13,18 +13,20 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
"enabled": true, "enabled": true,
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
"allow_from": ["123456789"], "allow_from": ["123456789"],
"proxy": "" "proxy": "",
"use_markdown_v2": false
} }
} }
} }
``` ```
| Field | Type | Required | Description | | Field | Type | Required | Description |
| ---------- | ------ | -------- | ------------------------------------------------------------------ | | ---------------- | ------ | -------- | ------------------------------------------------------------------ |
| enabled | bool | Yes | Whether to enable the Telegram channel | | enabled | bool | Yes | Whether to enable the Telegram channel |
| token | string | Yes | Telegram Bot API Token | | token | string | Yes | Telegram Bot API Token |
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | | 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) | | proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) |
| use_markdown_v2 | bool | No | Enable Telegram MarkdownV2 formatting |
## Setup ## Setup
@ -53,3 +55,20 @@ Examples:
/use git /use git
explain how to squash the last 3 commits explain how to squash the last 3 commits
``` ```
## Advanced Formatting
You can set `use_markdown_v2: true` to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks.
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"use_markdown_v2": true
}
}
}
```

View file

@ -13,18 +13,20 @@ O canal Telegram utiliza long polling via a API de Bot do Telegram para comunica
"enabled": true, "enabled": true,
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
"allow_from": ["123456789"], "allow_from": ["123456789"],
"proxy": "" "proxy": "",
"use_markdown_v2": false
} }
} }
} }
``` ```
| Campo | Tipo | Obrigatório | Descrição | | Campo | Tipo | Obrigatório | Descrição |
| ---------- | ------ | ----------- | -------------------------------------------------------------------------- | | --------------- | ------ | ----------- | -------------------------------------------------------------------------- |
| enabled | bool | Sim | Se o canal Telegram deve ser habilitado | | enabled | bool | Sim | Se o canal Telegram deve ser habilitado |
| token | string | Sim | Token da API de Bot do Telegram | | 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 | | 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) | | proxy | string | Não | URL do proxy para conexão com a API do Telegram (ex. http://127.0.0.1:7890) |
| use_markdown_v2 | bool | Não | Habilitar formatação Telegram MarkdownV2 |
## Configuração inicial ## Configuração inicial
@ -33,3 +35,20 @@ O canal Telegram utiliza long polling via a API de Bot do Telegram para comunica
3. Obtenha o Token da API HTTP 3. Obtenha o Token da API HTTP
4. Preencha o Token no arquivo de configuração 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`) 5. (Opcional) Configure `allow_from` para restringir quais IDs de usuário podem interagir (os IDs podem ser obtidos via `@userinfobot`)
## Formatação Avançada
Você pode definir `use_markdown_v2: true` para habilitar opções de formatação aprimoradas. Isso permite que o bot utilize todos os recursos do Telegram MarkdownV2, incluindo estilos aninhados, spoilers e blocos de largura fixa personalizados.
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"use_markdown_v2": true
}
}
}
```

View file

@ -13,18 +13,20 @@ Kênh Telegram sử dụng long polling qua Telegram Bot API để giao tiếp d
"enabled": true, "enabled": true,
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
"allow_from": ["123456789"], "allow_from": ["123456789"],
"proxy": "" "proxy": "",
"use_markdown_v2": false
} }
} }
} }
``` ```
| Trường | Kiểu | Bắt buộc | Mô tả | | Trường | Kiểu | Bắt buộc | Mô tả |
| ---------- | ------ | -------- | ------------------------------------------------------------------------ | | -------------- | ------ | -------- | ------------------------------------------------------------------------ |
| enabled | bool | Có | Có bật kênh Telegram hay không | | enabled | bool | Có | Có bật kênh Telegram hay không |
| token | string | Có | Token API Bot Telegram | | 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ả | | 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) | | proxy | string | Không | URL proxy để kết nối với Telegram API (ví dụ: http://127.0.0.1:7890) |
| use_markdown_v2 | bool | Không | Bật định dạng Telegram MarkdownV2 |
## Hướng dẫn thiết lập ## Hướng dẫn thiết lập
@ -33,3 +35,20 @@ Kênh Telegram sử dụng long polling qua Telegram Bot API để giao tiếp d
3. Lấy Token API HTTP 3. Lấy Token API HTTP
4. Điền Token vào file cấu hình 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`) 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`)
## Định dạng nâng cao
Bạn có thể đặt `use_markdown_v2: true` để bật các tùy chọn định dạng nâng cao. Điều này cho phép bot sử dụng toàn bộ các tính năng của Telegram MarkdownV2, bao gồm các kiểu lồng nhau, spoiler và các khối chiều rộng cố định tùy chỉnh.
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"use_markdown_v2": true
}
}
}
```

View file

@ -13,18 +13,20 @@ Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器
"enabled": true, "enabled": true,
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
"allow_from": ["123456789"], "allow_from": ["123456789"],
"proxy": "" "proxy": "",
"use_markdown_v2": false
} }
} }
} }
``` ```
| 字段 | 类型 | 必填 | 描述 | | 字段 | 类型 | 必填 | 描述 |
| ---------- | ------ | ---- | --------------------------------------------------------- | | ---------------- | ------ | ---- | --------------------------------------------------------- |
| enabled | bool | 是 | 是否启用 Telegram 频道 | | enabled | bool | 是 | 是否启用 Telegram 频道 |
| token | string | 是 | Telegram 机器人 API Token | | token | string | 是 | Telegram 机器人 API Token |
| allow_from | array | 否 | 用户ID白名单空表示允许所有用户 | | allow_from | array | 否 | 用户ID白名单空表示允许所有用户 |
| proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) | | proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) |
| use_markdown_v2 | bool | 否 | 启用 Telegram MarkdownV2 格式化 |
## 设置流程 ## 设置流程
@ -50,6 +52,23 @@ Telegram 会在启动时自动注册 PicoClaw 的顶级 Bot 命令,包括 `/st
```text ```text
/list skills /list skills
/use git explain how to squash the last 3 commits /use git explain how to squash the last 3 commits
/use italiapersonalfinance /use git
dammi le ultime news explain how to squash the last 3 commits
```
## 高级格式化
您可以设置 `use_markdown_v2: true` 来启用增强的格式化选项。这允许机器人使用 Telegram MarkdownV2 的全部功能,包括嵌套样式、剧透和自定义等宽代码块。
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"use_markdown_v2": true
}
}
}
``` ```

6
go.mod
View file

@ -27,6 +27,8 @@ require (
github.com/mymmrac/telego v1.7.0 github.com/mymmrac/telego v1.7.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0 github.com/openai/openai-go/v3 v3.22.0
github.com/pion/rtp v1.8.7
github.com/pion/webrtc/v3 v3.3.6
github.com/rivo/tview v0.42.0 github.com/rivo/tview v0.42.0
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/slack-go/slack v0.17.3 github.com/slack-go/slack v0.17.3
@ -61,6 +63,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
github.com/aws/smithy-go v1.24.2 // indirect github.com/aws/smithy-go v1.24.2 // indirect
github.com/beeper/argo-go v1.1.2 // indirect github.com/beeper/argo-go v1.1.2 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
@ -76,6 +79,7 @@ require (
github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
@ -123,3 +127,5 @@ require (
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 golang.org/x/sys v0.42.0
) )
replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532

13
go.sum
View file

@ -53,8 +53,6 @@ github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@ -65,6 +63,8 @@ github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoG
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
@ -204,6 +204,12 @@ github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixi
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE= 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/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtp v1.8.7 h1:qslKkG8qxvQ7hqaxkmL7Pl0XcUm+/Er7nMnu6Vq+ZxM=
github.com/pion/rtp v1.8.7/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 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/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -273,6 +279,8 @@ github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTd
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532 h1:gxFHYeUDGziRb0zXYEqBFohC+NJbIW9L0tddaXMWr2o=
github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532/go.mod h1:A0FcMFJKJ9fRjgSuZ2o+pIQ6mPS81SVuiLN2vYTa7Ao=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@ -300,7 +308,6 @@ golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
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.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.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=

View file

@ -18,6 +18,8 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/sipeed/picoclaw/pkg/audio/asr"
"github.com/sipeed/picoclaw/pkg/audio/tts"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/commands"
@ -31,7 +33,6 @@ import (
"github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
"github.com/sipeed/picoclaw/pkg/voice"
) )
type AgentLoop struct { type AgentLoop struct {
@ -51,7 +52,7 @@ type AgentLoop struct {
fallback *providers.FallbackChain fallback *providers.FallbackChain
channelManager *channels.Manager channelManager *channels.Manager
mediaStore media.MediaStore mediaStore media.MediaStore
transcriber voice.Transcriber transcriber asr.Transcriber
cmdRegistry *commands.Registry cmdRegistry *commands.Registry
mcp mcpRuntime mcp mcpRuntime
hookRuntime hookRuntime hookRuntime hookRuntime
@ -159,6 +160,13 @@ func registerSharedTools(
provider providers.LLMProvider, provider providers.LLMProvider,
) { ) {
allowReadPaths := buildAllowReadPatterns(cfg) allowReadPaths := buildAllowReadPatterns(cfg)
var ttsProvider tts.TTSProvider
if cfg.Tools.IsToolEnabled("send_tts") {
ttsProvider = tts.DetectTTS(cfg)
if ttsProvider == nil {
logger.WarnCF("voice-tts", "send_tts enabled but no TTS provider configured", nil)
}
}
for _, agentID := range registry.ListAgentIDs() { for _, agentID := range registry.ListAgentIDs() {
agent, ok := registry.GetAgent(agentID) agent, ok := registry.GetAgent(agentID)
@ -269,6 +277,10 @@ func registerSharedTools(
agent.Tools.Register(sendFileTool) agent.Tools.Register(sendFileTool)
} }
if ttsProvider != nil {
agent.Tools.Register(tools.NewSendTTSTool(ttsProvider, nil))
}
// Skill discovery and installation tools // Skill discovery and installation tools
skills_enabled := cfg.Tools.IsToolEnabled("skills") skills_enabled := cfg.Tools.IsToolEnabled("skills")
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
@ -1059,10 +1071,15 @@ func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
agent.Tools.SetMediaStore(s) agent.Tools.SetMediaStore(s)
} }
} }
registry.ForEachTool("send_tts", func(t tools.Tool) {
if st, ok := t.(*tools.SendTTSTool); ok {
st.SetMediaStore(s)
}
})
} }
// SetTranscriber injects a voice transcriber for agent-level audio transcription. // SetTranscriber injects a voice transcriber for agent-level audio transcription.
func (al *AgentLoop) SetTranscriber(t voice.Transcriber) { func (al *AgentLoop) SetTranscriber(t asr.Transcriber) {
al.transcriber = t al.transcriber = t
} }
@ -1083,19 +1100,23 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou
// Transcribe each audio media ref in order. // Transcribe each audio media ref in order.
var transcriptions []string var transcriptions []string
var keptMedia []string
for _, ref := range msg.Media { for _, ref := range msg.Media {
path, meta, err := al.mediaStore.ResolveWithMeta(ref) path, meta, err := al.mediaStore.ResolveWithMeta(ref)
if err != nil { if err != nil {
logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err}) logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err})
keptMedia = append(keptMedia, ref)
continue continue
} }
if !utils.IsAudioFile(meta.Filename, meta.ContentType) { if !utils.IsAudioFile(meta.Filename, meta.ContentType) {
keptMedia = append(keptMedia, ref)
continue continue
} }
result, err := al.transcriber.Transcribe(ctx, path) result, err := al.transcriber.Transcribe(ctx, path)
if err != nil { if err != nil {
logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err}) logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err})
transcriptions = append(transcriptions, "") transcriptions = append(transcriptions, "")
keptMedia = append(keptMedia, ref)
continue continue
} }
transcriptions = append(transcriptions, result.Text) transcriptions = append(transcriptions, result.Text)
@ -1115,15 +1136,21 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou
} }
text := transcriptions[idx] text := transcriptions[idx]
idx++ idx++
if text == "" {
return match
}
return "[voice: " + text + "]" return "[voice: " + text + "]"
}) })
// Append any remaining transcriptions not matched by an annotation. // Append any remaining transcriptions not matched by an annotation.
for ; idx < len(transcriptions); idx++ { for ; idx < len(transcriptions); idx++ {
if transcriptions[idx] != "" {
newContent += "\n[voice: " + transcriptions[idx] + "]" newContent += "\n[voice: " + transcriptions[idx] + "]"
} }
}
msg.Content = newContent msg.Content = newContent
msg.Media = keptMedia
return msg, true return msg, true
} }
@ -2549,6 +2576,28 @@ turnLoop:
if toolResult == nil { if toolResult == nil {
toolResult = tools.ErrorResult("hook returned nil tool result") toolResult = tools.ErrorResult("hook returned nil tool result")
} }
// Send ForUser if not silent and has content.
// For ResponseHandled tools, send regardless of SendResponse setting,
// since they've already handled the response (e.g., send_tts, send_file).
shouldSendForUser := !toolResult.Silent && toolResult.ForUser != "" &&
(ts.opts.SendResponse || toolResult.ResponseHandled)
if shouldSendForUser {
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: ts.channel,
ChatID: ts.chatID,
Content: toolResult.ForUser,
Metadata: map[string]string{
"is_tool_call": "true",
},
})
logger.DebugCF("agent", "Sent tool result to user",
map[string]any{
"tool": toolName,
"content_len": len(toolResult.ForUser),
})
}
if len(toolResult.Media) > 0 && toolResult.ResponseHandled { if len(toolResult.Media) > 0 && toolResult.ResponseHandled {
parts := make([]bus.MediaPart, 0, len(toolResult.Media)) parts := make([]bus.MediaPart, 0, len(toolResult.Media))
for _, ref := range toolResult.Media { for _, ref := range toolResult.Media {
@ -2594,19 +2643,6 @@ turnLoop:
allResponsesHandled = false allResponsesHandled = false
} }
if !toolResult.Silent && toolResult.ForUser != "" && ts.opts.SendResponse {
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: ts.channel,
ChatID: ts.chatID,
Content: toolResult.ForUser,
})
logger.DebugCF("agent", "Sent tool result to user",
map[string]any{
"tool": toolName,
"content_len": len(toolResult.ForUser),
})
}
contentForLLM := toolResult.ContentForLLM() contentForLLM := toolResult.ContentForLLM()
// Filter sensitive data (API keys, tokens, secrets) before sending to LLM // Filter sensitive data (API keys, tokens, secrets) before sending to LLM

166
pkg/audio/asr/README.md Normal file
View file

@ -0,0 +1,166 @@
# ASR (Automatic Speech Recognition)
This package handles speech-to-text for PicoClaw voice input.
If you are new to ASR setup, the simplest mental model is:
1. Add one or more ASR-capable entries to `model_list`.
2. Point `voice.model_name` at the one you want to use.
3. Put the API key in `.security.yml`.
## Quick Recommendation
For most new users, start with one of these:
| Provider | Example model | Why start here |
| --- | --- | --- |
| [Groq](https://console.groq.com/keys) | `groq/whisper-large-v3-turbo` | Fast Whisper-style transcription and a straightforward OpenAI-compatible API. Groq currently advertises a free tier plan for 2000 reqs/day. |
| [ElevenLabs](https://elevenlabs.io/pricing) | `elevenlabs/scribe_v1` | Easy setup and strong speech-to-text quality. ElevenLabs currently advertises a free plan that includes speech-to-text usage. |
Pricing and free-plan limits can change, so check the linked pricing pages before depending on them in production.
## How ASR Configuration Works
PicoClaw does not keep ASR API keys inside the `voice` section.
Instead:
- `voice.model_name` chooses a named entry from `model_list`.
- The matching `model_list` entry describes the actual provider and model.
- `.security.yml` stores the API key for that named model entry.
This is the recommended pattern because it is explicit, reusable, and consistent with the rest of PicoClaw's model configuration.
## Recommended Setup
### Option A: Groq Whisper
`config.json`
```json
{
"voice": {
"model_name": "groq-asr",
"echo_transcription": true
},
"model_list": [
{
"model_name": "groq-asr",
"model": "groq/whisper-large-v3-turbo"
}
]
}
```
`.security.yml`
```yaml
model_list:
groq-asr:
api_keys:
- "gsk_your_groq_key"
```
Notes:
- You can omit `api_base` and PicoClaw will use Groq's default API base automatically.
- If you set `api_base` manually for Groq Whisper, both of these forms work:
- `https://api.groq.com/openai/v1`
- `https://api.groq.com/openai/v1/audio/transcriptions`
- Any OpenAI-compatible Whisper model name containing `whisper` can use the Whisper transcription path, not only `whisper-large-v3-turbo`.
### Option B: ElevenLabs
`config.json`
```json
{
"voice": {
"model_name": "elevenlabs-asr",
"echo_transcription": true
},
"model_list": [
{
"model_name": "elevenlabs-asr",
"model": "elevenlabs/scribe_v1"
}
]
}
```
`.security.yml`
```yaml
model_list:
elevenlabs-asr:
api_keys:
- "sk-elevenlabs-your-key"
```
### Option C: OpenAI Whisper
`config.json`
```json
{
"voice": {
"model_name": "openai-asr"
},
"model_list": [
{
"model_name": "openai-asr",
"model": "openai/whisper-1"
}
]
}
```
`.security.yml`
```yaml
model_list:
openai-asr:
api_keys:
- "sk-openai-your-key"
```
## Other ASR-Capable Model Types
PicoClaw currently supports three main ASR routes:
| Route | Example models | Behavior |
| --- | --- | --- |
| ElevenLabs ASR | `elevenlabs/scribe_v1` | Uses the ElevenLabs transcription API. |
| Whisper endpoint models | `openai/whisper-1`, `groq/whisper-large-v3` | Uses an OpenAI-compatible `/audio/transcriptions` endpoint. |
| Audio-capable chat models **(Under construction)** | `openai/gpt-4o-audio-preview`, `gemini/gemini-2.5-flash` | Sends audio to a multimodal chat model and asks it to transcribe. |
If you are unsure which one to pick, choose Groq Whisper or ElevenLabs first.
## How PicoClaw Chooses a Transcriber
`DetectTranscriber` resolves ASR in this order:
1. **Preferred path**: resolve `voice.model_name` against `model_list`.
2. If that resolved model is:
- `elevenlabs/...`, PicoClaw uses the ElevenLabs transcriber.
- an OpenAI-compatible Whisper model, PicoClaw uses the Whisper transcriber.
- an audio-capable chat model, PicoClaw uses `AudioModelTranscriber`.
3. **Fallback path**: if `voice.model_name` is not set, PicoClaw performs a compatibility scan through `model_list` for legacy auto-detected ASR entries.
Fallback scanning exists for backward compatibility. New configurations should set `voice.model_name` explicitly.
## Common Mistakes
- Defining an ASR model in `model_list` but forgetting to set `voice.model_name`.
- Putting the API key in `voice` instead of `.security.yml`.
- Using a non-ASR model and expecting Whisper-style transcription behavior.
- Setting a custom `api_base` that points to the wrong provider endpoint.
## Minimal Checklist
Before testing voice input, make sure:
- `voice.model_name` matches a `model_list[].model_name`.
- The matching `.security.yml` entry contains a valid API key.
- The selected model is actually ASR-capable.
- Voice input is enabled for the channel you are using.

166
pkg/audio/asr/README_zh.md Normal file
View file

@ -0,0 +1,166 @@
# ASR自动语音识别
这个目录负责 PicoClaw 的语音转文字能力。
如果你是第一次配置 ASR可以参考如下步骤
1. 在 `model_list` 里添加一个或多个支持 ASR 的模型条目。
2. 用 `voice.model_name` 指向你想使用的那个条目。
3. 在 `.security.yml` 里配置对应的 API Key。
## 快速推荐
对于大多数新用户,建议先从下面两种开始:
| 提供商 | 示例模型 | 推荐理由 |
| --- | --- | --- |
| [Groq](https://console.groq.com/keys) | `groq/whisper-large-v3-turbo` | Whisper 风格转录速度快,并且提供 OpenAI 兼容接口配置比较直接。Groq 目前官方提供2000请求每日的免费套餐。 |
| [ElevenLabs](https://elevenlabs.io/pricing) | `elevenlabs/scribe_v1` | 上手简单语音转文字质量也不错。ElevenLabs 目前官方免费套餐包含 STT 用量。 |
价格和免费额度可能会变化,正式使用前请以官网定价页为准。
## ASR 配置是如何工作的
PicoClaw 不会把 ASR 的 API Key 放在 `voice` 配置里。
推荐的方式是:
- `voice.model_name` 用来选择 `model_list` 里的某个命名模型。
- `model_list` 条目描述真实的提供商和模型。
- `.security.yml` 负责保存该模型条目的 API Key。
这种方式更明确、更安全,也和 PicoClaw 其他模型配置方式保持一致。
## 推荐配置方式
### 方案 AGroq Whisper
`config.json`
```json
{
"voice": {
"model_name": "groq-asr",
"echo_transcription": true
},
"model_list": [
{
"model_name": "groq-asr",
"model": "groq/whisper-large-v3-turbo"
}
]
}
```
`.security.yml`
```yaml
model_list:
groq-asr:
api_keys:
- "gsk_your_groq_key"
```
说明:
- 你可以不写 `api_base`PicoClaw 会自动使用 Groq 默认接口地址。
- 如果你手动设置 Groq Whisper 的 `api_base`,下面两种写法都可以:
- `https://api.groq.com/openai/v1`
- `https://api.groq.com/openai/v1/audio/transcriptions`
- 只要是 OpenAI 兼容、并且模型名里包含 `whisper` 的模型,都可以走 Whisper 转录路径,不仅限于 `whisper-large-v3-turbo`
### 方案 BElevenLabs
`config.json`
```json
{
"voice": {
"model_name": "elevenlabs-asr",
"echo_transcription": true
},
"model_list": [
{
"model_name": "elevenlabs-asr",
"model": "elevenlabs/scribe_v1"
}
]
}
```
`.security.yml`
```yaml
model_list:
elevenlabs-asr:
api_keys:
- "sk-elevenlabs-your-key"
```
### 方案 COpenAI Whisper
`config.json`
```json
{
"voice": {
"model_name": "openai-asr"
},
"model_list": [
{
"model_name": "openai-asr",
"model": "openai/whisper-1"
}
]
}
```
`.security.yml`
```yaml
model_list:
openai-asr:
api_keys:
- "sk-openai-your-key"
```
## 其他支持 ASR 的模型类型
PicoClaw 目前主要支持三种 ASR 路径:
| 路径 | 示例模型 | 行为说明 |
| --- | --- | --- |
| ElevenLabs ASR | `elevenlabs/scribe_v1` | 使用 ElevenLabs 的语音转录接口。 |
| Whisper 接口模型 | `openai/whisper-1``groq/whisper-large-v3` | 使用 OpenAI 兼容的 `/audio/transcriptions` 接口。 |
| 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview``gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 |
如果你不确定该选哪种,建议优先使用 Groq Whisper 或 ElevenLabs。
## PicoClaw 如何选择转录器
`DetectTranscriber` 会按下面顺序选择 ASR
1. **首选路径**:根据 `voice.model_name``model_list` 中找到对应模型。
2. 如果找到的模型属于以下类型:
- `elevenlabs/...`,则使用 ElevenLabs transcriber。
- OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。
- 支持音频输入的聊天模型,则使用 `AudioModelTranscriber`
3. **回退路径**:如果没有设置 `voice.model_name`PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。
回退扫描只是为了兼容旧行为。新配置建议始终显式设置 `voice.model_name`
## 常见错误
- 在 `model_list` 里定义了 ASR 模型,但忘了设置 `voice.model_name`
- 把 API Key 写进了 `voice`,而不是 `.security.yml`
- 选择了不支持 ASR 的模型,却期望得到 Whisper 风格的转录结果。
- 自定义了错误的 `api_base`,导致请求打到错误的接口地址。
## 最小检查清单
在测试语音输入前,请确认:
- `voice.model_name` 能正确匹配某个 `model_list[].model_name`
- `.security.yml` 中对应条目已经配置了有效 API Key。
- 你选择的模型确实支持 ASR。
- 你当前使用的频道已经启用了语音输入能力。

252
pkg/audio/asr/agent.go Normal file
View file

@ -0,0 +1,252 @@
package asr
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/pion/rtp"
"github.com/pion/webrtc/v3/pkg/media/oggwriter"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger"
)
type speechAccumulator struct {
writer *oggwriter.OggWriter
file string
lastAudioAt time.Time
mu sync.Mutex
closed bool
chatID string
speakerID string
sessionID string
channel string
}
func (a *speechAccumulator) Push(chunk bus.AudioChunk) {
a.mu.Lock()
defer a.mu.Unlock()
if a.closed {
return
}
a.lastAudioAt = time.Now()
pkt := &rtp.Packet{
Header: rtp.Header{
SequenceNumber: uint16(chunk.Sequence),
Timestamp: chunk.Timestamp,
SSRC: 1, // Stable arbitrary dummy
},
Payload: chunk.Data,
}
if err := a.writer.WriteRTP(pkt); err != nil {
logger.ErrorCF("voice-agent", "Failed to write RTP", map[string]any{"error": err})
}
}
func (a *speechAccumulator) Close() {
a.mu.Lock()
defer a.mu.Unlock()
if !a.closed {
a.writer.Close()
a.closed = true
}
}
type Agent struct {
bus *bus.MessageBus
transcriber Transcriber
mu sync.Mutex
sessions map[string]*speechAccumulator // keyed by sessionID_speakerID
}
func NewAgent(mb *bus.MessageBus, t Transcriber) *Agent {
return &Agent{
bus: mb,
transcriber: t,
sessions: make(map[string]*speechAccumulator),
}
}
func (a *Agent) Start(ctx context.Context) {
logger.InfoCF("voice-agent", "Started Voice Agent orchestrator", nil)
go a.listenChunks(ctx)
go a.vadTick(ctx)
// Cleanup sessions on shutdown
go func() {
<-ctx.Done()
a.mu.Lock()
for key, acc := range a.sessions {
acc.Close()
os.Remove(acc.file)
delete(a.sessions, key)
}
a.mu.Unlock()
logger.InfoCF("voice-agent", "Cleaned up voice sessions on shutdown", nil)
}()
}
func (a *Agent) listenChunks(ctx context.Context) {
chunks := a.bus.AudioChunksChan()
for {
select {
case <-ctx.Done():
return
case chunk, ok := <-chunks:
if !ok {
return
}
a.handleChunk(chunk)
}
}
}
func (a *Agent) handleChunk(chunk bus.AudioChunk) {
// Only accept Opus-encoded audio
if chunk.Format != "opus" {
logger.DebugCF("voice-agent", "Ignoring unsupported audio format", map[string]any{"format": chunk.Format})
return
}
key := fmt.Sprintf("%s_%s", chunk.SessionID, chunk.SpeakerID)
a.mu.Lock()
acc, exists := a.sessions[key]
if !exists {
filename := filepath.Join(os.TempDir(), fmt.Sprintf("voice_%s_%d.ogg", key, time.Now().UnixNano()))
writer, err := oggwriter.New(filename, uint32(chunk.SampleRate), uint16(chunk.Channels))
if err != nil {
a.mu.Unlock()
logger.ErrorCF("voice-agent", "Failed to create OggWriter", map[string]any{"error": err})
return
}
acc = &speechAccumulator{
writer: writer,
file: filename,
lastAudioAt: time.Now(),
chatID: chunk.ChatID,
speakerID: chunk.SpeakerID,
sessionID: chunk.SessionID,
channel: chunk.Channel,
}
a.sessions[key] = acc
logger.DebugCF("voice-agent", "Started accumulating voice", map[string]any{"key": key, "file": filename})
}
a.mu.Unlock()
acc.Push(chunk)
}
func (a *Agent) vadTick(ctx context.Context) {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.checkSilence(ctx)
}
}
}
func (a *Agent) checkSilence(ctx context.Context) {
a.mu.Lock()
now := time.Now()
var finished []*speechAccumulator
for key, acc := range a.sessions {
acc.mu.Lock()
last := acc.lastAudioAt
acc.mu.Unlock()
if now.Sub(last) > 1500*time.Millisecond {
acc.Close()
delete(a.sessions, key)
finished = append(finished, acc)
}
}
a.mu.Unlock()
for _, acc := range finished {
go a.processUtterance(ctx, acc)
}
}
func (a *Agent) processUtterance(ctx context.Context, acc *speechAccumulator) {
defer os.Remove(acc.file)
logger.InfoCF("voice-agent", "User finished speaking, transcribing...", map[string]any{"file": acc.file})
if a.transcriber == nil {
logger.ErrorCF("voice-agent", "No STT configured!", nil)
return
}
res, err := a.transcriber.Transcribe(ctx, acc.file)
if err != nil {
logger.ErrorCF("voice-agent", "Transcription failed", map[string]any{"error": err})
return
}
if res.Text == "" {
logger.DebugCF("voice-agent", "Ignored empty transcription", map[string]any{"file": acc.file})
return
}
logger.InfoCF("voice-agent", "Transcription result", map[string]any{"text": res.Text, "duration": res.Duration})
channelType := acc.channel
if channelType == "" {
channelType = "discord" // fallback for legacy chunks
}
text := strings.ToLower(strings.TrimSpace(res.Text))
if strings.Contains(text, "leave the voice channel") || strings.Contains(text, "leave voice") ||
strings.Contains(text, "disconnect voice") || strings.Contains(text, "leave the channel") ||
strings.Contains(text, "leave channel") {
logger.InfoCF("voice-agent", "Voice command triggered: leave", nil)
if err := a.bus.PublishVoiceControl(ctx, bus.VoiceControl{
SessionID: acc.sessionID,
Type: "command",
Action: "leave",
}); err != nil {
logger.ErrorCF("voice-agent", "Failed to publish leave control", map[string]any{"error": err})
}
if err := a.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: channelType,
ChatID: acc.chatID,
Content: "Goodbye! Leaving the voice channel.",
}); err != nil {
logger.ErrorCF("voice-agent", "Failed to publish goodbye message", map[string]any{"error": err})
}
return
}
oralPrompt := "\n\n[SYSTEM]: The user just spoke this to you over voice chat. Please reply in a highly concise, conversational, oral style suitable for text-to-speech. Do not use markdown, emojis, asterisks, or code blocks. Speak naturally."
if err := a.bus.PublishInbound(ctx, bus.InboundMessage{
Channel: channelType,
SenderID: acc.speakerID,
ChatID: acc.chatID,
Content: res.Text + oralPrompt,
Peer: bus.Peer{Kind: "channel", ID: acc.chatID},
Metadata: map[string]string{
"is_voice": "true",
},
}); err != nil {
logger.ErrorCF("voice-agent", "Failed to publish inbound message", map[string]any{"error": err})
}
}

196
pkg/audio/asr/agent_test.go Normal file
View file

@ -0,0 +1,196 @@
package asr
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/pion/webrtc/v3/pkg/media/oggwriter"
"github.com/sipeed/picoclaw/pkg/bus"
)
type fakeTranscriber struct {
text string
err error
lastPath string
}
func (f *fakeTranscriber) Name() string { return "fake" }
func (f *fakeTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
f.lastPath = audioFilePath
if f.err != nil {
return nil, f.err
}
return &TranscriptionResponse{Text: f.text}, nil
}
func waitForFileRemoval(t *testing.T, path string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); os.IsNotExist(err) {
return
}
time.Sleep(10 * time.Millisecond)
}
if _, err := os.Stat(path); err == nil {
t.Fatalf("expected file to be removed: %s", path)
}
}
func TestAgentHandleChunkCreatesSession(t *testing.T) {
t.Parallel()
mb := bus.NewMessageBus()
defer mb.Close()
agent := NewAgent(mb, &fakeTranscriber{})
chunk := bus.AudioChunk{
SessionID: "sess",
SpeakerID: "speaker",
ChatID: "chat",
Channel: "discord",
Sequence: 1,
Timestamp: 1,
SampleRate: 48000,
Channels: 2,
Format: "opus",
Data: []byte{0xF8, 0xFF, 0xFE},
}
agent.handleChunk(chunk)
key := "sess_speaker"
agent.mu.Lock()
acc, ok := agent.sessions[key]
agent.mu.Unlock()
if !ok {
t.Fatal("expected session to be created")
}
acc.Close()
_ = os.Remove(acc.file)
}
func TestAgentHandleChunkIgnoresUnsupportedFormat(t *testing.T) {
t.Parallel()
mb := bus.NewMessageBus()
defer mb.Close()
agent := NewAgent(mb, &fakeTranscriber{})
chunk := bus.AudioChunk{Format: "pcm"}
agent.handleChunk(chunk)
agent.mu.Lock()
count := len(agent.sessions)
agent.mu.Unlock()
if count != 0 {
t.Fatalf("expected no sessions, got %d", count)
}
}
func TestAgentProcessUtteranceLeaveCommand(t *testing.T) {
t.Parallel()
mb := bus.NewMessageBus()
defer mb.Close()
tr := &fakeTranscriber{text: "please leave the voice channel now"}
agent := NewAgent(mb, tr)
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "voice.ogg")
if err := os.WriteFile(filePath, []byte("data"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
acc := &speechAccumulator{
file: filePath,
chatID: "chat",
speakerID: "speaker",
sessionID: "sess",
channel: "discord",
}
agent.processUtterance(context.Background(), acc)
select {
case ctrl := <-mb.VoiceControlsChan():
if ctrl.Action != "leave" || ctrl.Type != "command" || ctrl.SessionID != "sess" {
t.Fatalf("unexpected voice control: %#v", ctrl)
}
case <-time.After(250 * time.Millisecond):
t.Fatal("expected voice control publish")
}
select {
case out := <-mb.OutboundChan():
if !strings.Contains(out.Content, "Leaving the voice channel") {
t.Fatalf("unexpected outbound content: %q", out.Content)
}
case <-time.After(250 * time.Millisecond):
t.Fatal("expected outbound publish")
}
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
t.Fatalf("expected temp file to be removed")
}
}
func TestAgentCheckSilencePublishesInboundAndCleansUp(t *testing.T) {
t.Parallel()
mb := bus.NewMessageBus()
defer mb.Close()
tr := &fakeTranscriber{text: "hello there"}
agent := NewAgent(mb, tr)
filePath := filepath.Join(t.TempDir(), "voice.ogg")
writer, err := oggwriter.New(filePath, 48000, 2)
if err != nil {
t.Fatalf("create ogg writer: %v", err)
}
acc := &speechAccumulator{
writer: writer,
file: filePath,
lastAudioAt: time.Now().Add(-2 * time.Second),
chatID: "chat",
speakerID: "speaker",
sessionID: "sess",
channel: "slack",
}
agent.mu.Lock()
agent.sessions["sess_speaker"] = acc
agent.mu.Unlock()
agent.checkSilence(context.Background())
select {
case msg := <-mb.InboundChan():
if msg.Channel != "slack" {
t.Fatalf("unexpected inbound channel: %q", msg.Channel)
}
if !strings.Contains(msg.Content, "hello there") {
t.Fatalf("unexpected inbound content: %q", msg.Content)
}
if msg.Metadata["is_voice"] != "true" {
t.Fatalf("expected is_voice metadata, got %#v", msg.Metadata)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("expected inbound publish")
}
waitForFileRemoval(t, filePath, 500*time.Millisecond)
}

131
pkg/audio/asr/asr.go Normal file
View file

@ -0,0 +1,131 @@
package asr
import (
"context"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
type Transcriber interface {
Name() string
Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
}
type TranscriptionResponse struct {
Text string `json:"text"`
Language string `json:"language,omitempty"`
Duration float64 `json:"duration,omitempty"`
}
func supportsAudioTranscription(model string) bool {
protocol, _ := providers.ExtractProtocol(model)
switch protocol {
case "openai", "azure", "azure-openai",
"litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding":
// These protocols all go through the OpenAI-compatible or Azure provider path in
// providers.CreateProviderFromConfig, so they are the only ones that can supply
// the audio media payload shape expected by NewAudioModelTranscriber.
// TODO: Further restrict this by modelID, since not every model under these
// protocols supports audio transcription.
return true
default:
return false
}
}
func supportsWhisperTranscription(model string) bool {
protocol, _ := providers.ExtractProtocol(model)
switch protocol {
case "openai", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding", "mimo":
return true
default:
return false
}
}
func whisperModelID(modelCfg *config.ModelConfig) string {
if modelCfg == nil || modelCfg.APIKey() == "" {
return ""
}
if !supportsWhisperTranscription(modelCfg.Model) {
return ""
}
_, modelID := providers.ExtractProtocol(strings.TrimSpace(modelCfg.Model))
if strings.Contains(strings.ToLower(modelID), "whisper") {
return modelID
}
return ""
}
func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber {
if modelCfg == nil {
return nil
}
protocol, _ := providers.ExtractProtocol(modelCfg.Model)
if protocol == "elevenlabs" && modelCfg.APIKey() != "" {
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
}
if modelID := whisperModelID(modelCfg); modelID != "" {
return NewWhisperTranscriber(modelCfg)
}
if supportsAudioTranscription(modelCfg.Model) {
return NewAudioModelTranscriber(modelCfg)
}
return nil
}
func fallbackTranscriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber {
if modelCfg == nil {
return nil
}
protocol, _ := providers.ExtractProtocol(modelCfg.Model)
if protocol == "elevenlabs" && modelCfg.APIKey() != "" {
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
}
if modelID := whisperModelID(modelCfg); modelID != "" {
return NewWhisperTranscriber(modelCfg)
}
return nil
}
// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or
// nil if no supported transcription provider is configured.
func DetectTranscriber(cfg *config.Config) Transcriber {
if cfg == nil {
return nil
}
if modelName := strings.TrimSpace(cfg.Voice.ModelName); modelName != "" {
modelCfg, err := cfg.GetModelConfig(modelName)
if err == nil {
if tr := transcriberFromModelConfig(modelCfg); tr != nil {
return tr
}
}
}
// Fall back to compatibility scanning for legacy auto-detected ASR providers.
for _, mc := range cfg.ModelList {
if tr := fallbackTranscriberFromModelConfig(mc); tr != nil {
return tr
}
}
return nil
}

View file

@ -1,4 +1,4 @@
package voice package asr
import ( import (
"testing" "testing"
@ -33,26 +33,68 @@ func TestDetectTranscriber(t *testing.T) {
wantName: "audio-model", wantName: "audio-model",
}, },
{ {
name: "groq via model list", name: "voice model name alias selects elevenlabs transcriber",
cfg: &config.Config{
Voice: config.VoiceConfig{ModelName: "my-asr-model"},
ModelList: []*config.ModelConfig{
{
ModelName: "my-asr-model",
Model: "elevenlabs/scribe_v1",
APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
},
},
},
wantName: "elevenlabs",
},
{
name: "voice model name alias selects whisper transcriber for groq",
cfg: &config.Config{
Voice: config.VoiceConfig{ModelName: "my-asr-model"},
ModelList: []*config.ModelConfig{
{
ModelName: "my-asr-model",
Model: "groq/whisper-large-v3",
APIKeys: config.SimpleSecureStrings("sk-groq-model"),
},
},
},
wantName: "whisper",
},
{
name: "openai whisper alias selects whisper transcriber",
cfg: &config.Config{
Voice: config.VoiceConfig{ModelName: "my-asr-model"},
ModelList: []*config.ModelConfig{
{
ModelName: "my-asr-model",
Model: "openai/whisper-1",
APIKeys: config.SimpleSecureStrings("sk-openai-model"),
},
},
},
wantName: "whisper",
},
{
name: "whisper via model list fallback",
cfg: &config.Config{ cfg: &config.Config{
ModelList: []*config.ModelConfig{ ModelList: []*config.ModelConfig{
{ModelName: "openai", Model: "openai/gpt-4o", APIKeys: config.SimpleSecureStrings("sk-openai")}, {ModelName: "openai", Model: "openai/gpt-4o", APIKeys: config.SimpleSecureStrings("sk-openai")},
{ {
ModelName: "groq", ModelName: "groq",
Model: "groq/llama-3.3-70b", Model: "groq/whisper-large-v3-turbo",
APIKeys: config.SimpleSecureStrings("sk-groq-model"), APIKeys: config.SimpleSecureStrings("sk-groq-model"),
}, },
}, },
}, },
wantName: "groq", wantName: "whisper",
}, },
{ {
name: "voice model name selects non-gemini audio model transcriber", name: "voice model name alias selects non-gemini audio model transcriber",
cfg: &config.Config{ cfg: &config.Config{
Voice: config.VoiceConfig{ModelName: "voice-openai-audio"}, Voice: config.VoiceConfig{ModelName: "my-asr-model"},
ModelList: []*config.ModelConfig{ ModelList: []*config.ModelConfig{
{ {
ModelName: "voice-openai-audio", ModelName: "my-asr-model",
Model: "openai/gpt-4o-audio-preview", Model: "openai/gpt-4o-audio-preview",
APIKeys: config.SimpleSecureStrings("sk-openai"), APIKeys: config.SimpleSecureStrings("sk-openai"),
}, },
@ -92,7 +134,7 @@ func TestDetectTranscriber(t *testing.T) {
name: "groq model list entry without key is skipped", name: "groq model list entry without key is skipped",
cfg: &config.Config{ cfg: &config.Config{
ModelList: []*config.ModelConfig{ ModelList: []*config.ModelConfig{
{Model: "groq/llama-3.3-70b"}, {Model: "groq/whisper-large-v3"},
}, },
}, },
wantNil: true, wantNil: true,
@ -103,12 +145,12 @@ func TestDetectTranscriber(t *testing.T) {
ModelList: []*config.ModelConfig{ ModelList: []*config.ModelConfig{
{ {
ModelName: "groq", ModelName: "groq",
Model: "groq/llama-3.3-70b", Model: "groq/whisper-large-v3",
APIKeys: config.SimpleSecureStrings("sk-groq-model"), APIKeys: config.SimpleSecureStrings("sk-groq-model"),
}, },
}, },
}, },
wantName: "groq", wantName: "whisper",
}, },
{ {
name: "missing voice model name config returns nil", name: "missing voice model name config returns nil",
@ -127,15 +169,17 @@ func TestDetectTranscriber(t *testing.T) {
{ {
name: "elevenlabs voice config key", name: "elevenlabs voice config key",
cfg: &config.Config{ cfg: &config.Config{
Voice: config.VoiceConfig{ElevenLabsAPIKey: "sk_elevenlabs_test"}, ModelList: []*config.ModelConfig{
{Model: "elevenlabs/scribe_v1", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test")},
},
}, },
wantName: "elevenlabs", wantName: "elevenlabs",
}, },
{ {
name: "elevenlabs takes priority over groq model list", name: "elevenlabs takes priority over groq model list",
cfg: &config.Config{ cfg: &config.Config{
Voice: config.VoiceConfig{ElevenLabsAPIKey: "sk_elevenlabs_test"},
ModelList: []*config.ModelConfig{ ModelList: []*config.ModelConfig{
{Model: "elevenlabs/scribe_v1", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test")},
{ {
ModelName: "groq", ModelName: "groq",
Model: "groq/llama-3.3-70b", Model: "groq/llama-3.3-70b",
@ -150,9 +194,9 @@ func TestDetectTranscriber(t *testing.T) {
cfg: &config.Config{ cfg: &config.Config{
Voice: config.VoiceConfig{ Voice: config.VoiceConfig{
ModelName: "voice-gemini", ModelName: "voice-gemini",
ElevenLabsAPIKey: "sk_elevenlabs_test",
}, },
ModelList: []*config.ModelConfig{ ModelList: []*config.ModelConfig{
{Model: "elevenlabs", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test")},
{ {
ModelName: "voice-gemini", ModelName: "voice-gemini",
Model: "gemini/gemini-2.5-flash", Model: "gemini/gemini-2.5-flash",

View file

@ -1,4 +1,4 @@
package voice package asr
import ( import (
"context" "context"

View file

@ -1,4 +1,4 @@
package voice package asr
import ( import (
"context" "context"

View file

@ -1,4 +1,4 @@
package voice package asr
import ( import (
"bytes" "bytes"
@ -23,12 +23,16 @@ type ElevenLabsTranscriber struct {
httpClient *http.Client httpClient *http.Client
} }
func NewElevenLabsTranscriber(apiKey string) *ElevenLabsTranscriber { func NewElevenLabsTranscriber(apiKey, apiBase string) *ElevenLabsTranscriber {
logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""}) logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""})
if apiBase == "" {
apiBase = "https://api.elevenlabs.io"
}
return &ElevenLabsTranscriber{ return &ElevenLabsTranscriber{
apiKey: apiKey, apiKey: apiKey,
apiBase: "https://api.elevenlabs.io", apiBase: apiBase,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 120 * time.Second, Timeout: 120 * time.Second,
}, },

View file

@ -1,4 +1,4 @@
package voice package asr
import ( import (
"context" "context"
@ -14,7 +14,7 @@ import (
var _ Transcriber = (*ElevenLabsTranscriber)(nil) var _ Transcriber = (*ElevenLabsTranscriber)(nil)
func TestElevenLabsTranscriberName(t *testing.T) { func TestElevenLabsTranscriberName(t *testing.T) {
tr := NewElevenLabsTranscriber("sk_test") tr := NewElevenLabsTranscriber("sk_test", "")
if got := tr.Name(); got != "elevenlabs" { if got := tr.Name(); got != "elevenlabs" {
t.Errorf("Name() = %q, want %q", got, "elevenlabs") t.Errorf("Name() = %q, want %q", got, "elevenlabs")
} }
@ -43,7 +43,7 @@ func TestElevenLabsTranscribe(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
tr := NewElevenLabsTranscriber("sk_test") tr := NewElevenLabsTranscriber("sk_test", "")
tr.apiBase = srv.URL tr.apiBase = srv.URL
resp, err := tr.Transcribe(context.Background(), audioPath) resp, err := tr.Transcribe(context.Background(), audioPath)
@ -64,7 +64,7 @@ func TestElevenLabsTranscribe(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
tr := NewElevenLabsTranscriber("sk_bad") tr := NewElevenLabsTranscriber("sk_bad", "")
tr.apiBase = srv.URL tr.apiBase = srv.URL
_, err := tr.Transcribe(context.Background(), audioPath) _, err := tr.Transcribe(context.Background(), audioPath)
@ -74,7 +74,7 @@ func TestElevenLabsTranscribe(t *testing.T) {
}) })
t.Run("missing file", func(t *testing.T) { t.Run("missing file", func(t *testing.T) {
tr := NewElevenLabsTranscriber("sk_test") tr := NewElevenLabsTranscriber("sk_test", "")
_, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg")) _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
if err == nil { if err == nil {
t.Fatal("expected error for missing file, got nil") t.Fatal("expected error for missing file, got nil")

View file

@ -0,0 +1,245 @@
package asr
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/utils"
)
type WhisperTranscriber struct {
apiKey string
apiBase string
modelID string
providerName string
httpClient *http.Client
}
func NewWhisperTranscriber(modelCfg *config.ModelConfig) *WhisperTranscriber {
if modelCfg == nil {
return nil
}
protocol, modelID := providers.ExtractProtocol(modelCfg.Model)
if modelID == "" {
modelID = strings.TrimSpace(modelCfg.Model)
}
tr := newWhisperTranscriber(
modelCfg.APIKey(),
providers.ResolveAPIBase(modelCfg),
modelID,
protocol,
)
if tr == nil {
return nil
}
logger.DebugCF("voice", "Creating whisper transcriber", map[string]any{
"api_base": tr.apiBase,
"has_key": tr.apiKey != "",
"model": tr.modelID,
"provider": tr.providerName,
})
return tr
}
func NewGroqTranscriber(apiKey, modelID string) *WhisperTranscriber {
return newWhisperTranscriber(apiKey, "https://api.groq.com/openai/v1", modelID, "groq")
}
func newWhisperTranscriber(apiKey, apiBase, modelID, providerName string) *WhisperTranscriber {
if modelID == "" {
return nil
}
if providerName == "" {
providerName = "whisper"
}
return &WhisperTranscriber{
apiKey: apiKey,
apiBase: strings.TrimRight(apiBase, "/"),
modelID: modelID,
providerName: providerName,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
}
}
func (t *WhisperTranscriber) transcriptionURL() string {
base := strings.TrimRight(t.apiBase, "/")
if strings.HasSuffix(base, "/audio/transcriptions") {
return base
}
return base + "/audio/transcriptions"
}
func (t *WhisperTranscriber) TranscribeData(
ctx context.Context,
data []byte,
filename string,
) (*TranscriptionResponse, error) {
logger.InfoCF("voice", "Starting whisper transcription from memory", map[string]any{
"bytes": len(data),
"filename": filename,
"model": t.modelID,
"provider": t.providerName,
})
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
logger.ErrorCF("voice", "Failed to create whisper form file", map[string]any{"error": err})
return nil, fmt.Errorf("failed to create form file: %w", err)
}
if _, copyErr := io.Copy(part, bytes.NewReader(data)); copyErr != nil {
logger.ErrorCF("voice", "Failed to copy whisper file content", map[string]any{"error": copyErr})
return nil, fmt.Errorf("failed to copy file content: %w", copyErr)
}
if err = writer.WriteField("model", t.modelID); err != nil {
logger.ErrorCF("voice", "Failed to write whisper model field", map[string]any{"error": err})
return nil, fmt.Errorf("failed to write model field: %w", err)
}
if err = writer.WriteField("response_format", "json"); err != nil {
logger.ErrorCF("voice", "Failed to write whisper response_format field", map[string]any{"error": err})
return nil, fmt.Errorf("failed to write response_format field: %w", err)
}
if err = writer.Close(); err != nil {
logger.ErrorCF("voice", "Failed to close whisper multipart writer", map[string]any{"error": err})
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
}
return t.doRequest(ctx, &requestBody, writer.FormDataContentType(), int64(len(data)))
}
func (t *WhisperTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
logger.InfoCF("voice", "Starting whisper transcription", map[string]any{
"audio_file": audioFilePath,
"model": t.modelID,
"provider": t.providerName,
})
audioFile, err := os.Open(audioFilePath)
if err != nil {
return nil, fmt.Errorf("failed to open audio file %s: %w", audioFilePath, err)
}
defer audioFile.Close()
fileInfo, err := audioFile.Stat()
if err != nil {
return nil, fmt.Errorf("failed to stat audio file %s: %w", audioFilePath, err)
}
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath))
if err != nil {
return nil, fmt.Errorf("failed to create form file: %w", err)
}
if _, copyErr := io.Copy(part, audioFile); copyErr != nil {
return nil, fmt.Errorf("failed to copy audio data: %w", copyErr)
}
if err = writer.WriteField("model", t.modelID); err != nil {
return nil, fmt.Errorf("failed to write model field: %w", err)
}
if err = writer.WriteField("response_format", "json"); err != nil {
return nil, fmt.Errorf("failed to write response_format field: %w", err)
}
if err = writer.Close(); err != nil {
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
}
return t.doRequest(ctx, &requestBody, writer.FormDataContentType(), fileInfo.Size())
}
func (t *WhisperTranscriber) doRequest(
ctx context.Context,
requestBody *bytes.Buffer,
contentType string,
fileSize int64,
) (*TranscriptionResponse, error) {
url := t.transcriptionURL()
req, err := http.NewRequestWithContext(ctx, "POST", url, requestBody)
if err != nil {
logger.ErrorCF("voice", "Failed to create whisper request", map[string]any{"error": err})
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", contentType)
if t.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+t.apiKey)
}
logger.DebugCF("voice", "Sending whisper transcription request", map[string]any{
"file_size_bytes": fileSize,
"model": t.modelID,
"provider": t.providerName,
"request_size_bytes": requestBody.Len(),
"url": url,
})
resp, err := t.httpClient.Do(req)
if err != nil {
logger.ErrorCF("voice", "Failed to send whisper request", map[string]any{"error": err})
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
logger.ErrorCF("voice", "Failed to read whisper response", map[string]any{"error": err})
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
logger.ErrorCF("voice", "Whisper API error", map[string]any{
"provider": t.providerName,
"response": string(body),
"status_code": resp.StatusCode,
})
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
var result TranscriptionResponse
if err := json.Unmarshal(body, &result); err != nil {
logger.ErrorCF("voice", "Failed to unmarshal whisper response", map[string]any{"error": err})
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
logger.InfoCF("voice", "Whisper transcription completed successfully", map[string]any{
"duration_seconds": result.Duration,
"language": result.Language,
"provider": t.providerName,
"text_length": len(result.Text),
"transcription_preview": utils.Truncate(result.Text, 50),
})
return &result, nil
}
func (t *WhisperTranscriber) Name() string {
return "whisper"
}

View file

@ -0,0 +1,102 @@
package asr
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestWhisperTranscriberTranscribeDataUsesConfiguredModel(t *testing.T) {
var gotModel string
var gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if got := r.Header.Get("Authorization"); got != "Bearer sk-openai-test" {
t.Errorf("Authorization = %q, want %q", got, "Bearer sk-openai-test")
}
reader, err := r.MultipartReader()
if err != nil {
t.Fatalf("MultipartReader() error: %v", err)
}
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("NextPart() error: %v", err)
}
data, err := io.ReadAll(part)
if err != nil {
t.Fatalf("ReadAll() error: %v", err)
}
if part.FormName() == "model" {
gotModel = string(data)
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(TranscriptionResponse{Text: "hello from whisper"}); err != nil {
t.Fatalf("Encode() error: %v", err)
}
}))
defer server.Close()
tr := NewWhisperTranscriber(&config.ModelConfig{
Model: "openai/whisper-1",
APIBase: server.URL,
APIKeys: config.SimpleSecureStrings("sk-openai-test"),
})
tr.httpClient = server.Client()
resp, err := tr.TranscribeData(context.Background(), []byte("audio"), "clip.ogg")
if err != nil {
t.Fatalf("TranscribeData() error: %v", err)
}
if resp.Text != "hello from whisper" {
t.Errorf("Text = %q, want %q", resp.Text, "hello from whisper")
}
if gotModel != "whisper-1" {
t.Errorf("model field = %q, want %q", gotModel, "whisper-1")
}
if gotPath != "/audio/transcriptions" {
t.Errorf("path = %q, want %q", gotPath, "/audio/transcriptions")
}
}
func TestWhisperTranscriberUsesEndpointAPIBaseWithoutDoubleAppend(t *testing.T) {
var gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(TranscriptionResponse{Text: "ok"}); err != nil {
t.Fatalf("Encode() error: %v", err)
}
}))
defer server.Close()
tr := NewWhisperTranscriber(&config.ModelConfig{
Model: "groq/whisper-large-v3",
APIBase: server.URL + "/audio/transcriptions",
APIKeys: config.SimpleSecureStrings("sk-groq-test"),
})
tr.httpClient = server.Client()
if _, err := tr.TranscribeData(context.Background(), []byte("audio"), "clip.ogg"); err != nil {
t.Fatalf("TranscribeData() error: %v", err)
}
if gotPath != "/audio/transcriptions" {
t.Errorf("path = %q, want %q", gotPath, "/audio/transcriptions")
}
}

57
pkg/audio/ogg.go Normal file
View file

@ -0,0 +1,57 @@
package audio
import (
"bytes"
"fmt"
"io"
)
// DecodeOggOpus reads an Ogg format stream and extracts individual Opus payloads.
// It calls onFrame for every complete Opus frame found in the stream.
func DecodeOggOpus(r io.Reader, onFrame func([]byte) error) error {
var packet bytes.Buffer
header := make([]byte, 27)
segment := make([]byte, 255)
for {
if _, err := io.ReadFull(r, header); err != nil {
if err == io.EOF || err == io.ErrUnexpectedEOF {
return nil
}
return fmt.Errorf("failed to read ogg header: %w", err)
}
if string(header[:4]) != "OggS" {
return fmt.Errorf("invalid ogg magic string")
}
pageSegments := int(header[26])
segmentTable := make([]byte, pageSegments)
if _, err := io.ReadFull(r, segmentTable); err != nil {
return fmt.Errorf("failed to read segment table: %w", err)
}
for _, lacing := range segmentTable {
if _, err := io.ReadFull(r, segment[:lacing]); err != nil {
return fmt.Errorf("failed to read segment data: %w", err)
}
packet.Write(segment[:lacing])
// If lacing is less than 255, the packet is complete
if lacing < 255 {
if packet.Len() > 0 {
packetBytes := packet.Bytes()
// Ignore Ogg Opus headers
if !bytes.HasPrefix(packetBytes, []byte("OpusHead")) &&
!bytes.HasPrefix(packetBytes, []byte("OpusTags")) {
if err := onFrame(packetBytes); err != nil {
return err
}
}
// Start new packet
packet.Reset()
}
}
}
}
}

146
pkg/audio/ogg_test.go Normal file
View file

@ -0,0 +1,146 @@
package audio
import (
"bytes"
"reflect"
"strings"
"testing"
)
// buildOggPage helper creates an Ogg page for testing.
// lacingVals specifies the segment table, and data is the payload.
func buildOggPage(lacingVals []byte, data []byte) []byte {
var buf bytes.Buffer
// 27-byte Ogg header
header := make([]byte, 27)
copy(header[:4], "OggS")
header[5] = 0 // type flag
// For testing, we only care about OggS magic and page_segments (byte 26)
header[26] = byte(len(lacingVals))
buf.Write(header)
buf.Write(lacingVals)
buf.Write(data)
return buf.Bytes()
}
func TestDecodeOggOpus_ValidParsing(t *testing.T) {
var b bytes.Buffer
// Packet 1: Single segment, length 50
pkt1 := bytes.Repeat([]byte{1}, 50)
// Packet 2: Multi-segment (255 + 10 = 265 bytes)
pkt2Part1 := bytes.Repeat([]byte{2}, 255)
pkt2Part2 := bytes.Repeat([]byte{2}, 10)
// Packet 3: Continued across pages. Page 1 gets 255, Page 2 gets 20. Total 275 bytes.
pkt3Part1 := bytes.Repeat([]byte{3}, 255)
pkt3Part2 := bytes.Repeat([]byte{3}, 20)
// Page 1: OpusHead (skip), OpusTags (skip), pkt1, pkt2, pkt3Part1
page1Lacing := []byte{8, 8, 50, 255, 10, 255}
page1Data := bytes.Join([][]byte{
[]byte("OpusHead"),
[]byte("OpusTags"),
pkt1,
pkt2Part1, pkt2Part2,
pkt3Part1,
}, nil)
// Page 2: pkt3Part2, pkt4 (length 10)
pkt4 := bytes.Repeat([]byte{4}, 10)
page2Lacing := []byte{20, 10}
page2Data := bytes.Join([][]byte{
pkt3Part2,
pkt4,
}, nil)
b.Write(buildOggPage(page1Lacing, page1Data))
b.Write(buildOggPage(page2Lacing, page2Data))
var frames [][]byte
err := DecodeOggOpus(&b, func(frame []byte) error {
// making a copy to store as DecodeOggOpus might reuse backing array
cpy := make([]byte, len(frame))
copy(cpy, frame)
frames = append(frames, cpy)
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expectedFrames := [][]byte{
pkt1,
append(pkt2Part1, pkt2Part2...),
append(pkt3Part1, pkt3Part2...),
pkt4,
}
if len(frames) != len(expectedFrames) {
t.Fatalf("expected %d frames, got %d", len(expectedFrames), len(frames))
}
for i, expected := range expectedFrames {
if !reflect.DeepEqual(frames[i], expected) {
t.Errorf("frame %d mismatch:\nexp: %v\ngot: %v", i, expected, frames[i])
}
}
}
func TestDecodeOggOpus_Errors(t *testing.T) {
tests := []struct {
name string
data []byte
errContains string
}{
{
name: "invalid magic string",
data: []byte(
"OggX\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
),
errContains: "invalid ogg magic string",
},
{
name: "short header",
data: []byte("Ogg"),
errContains: "failed to read ogg header",
},
{
name: "eof in segment table",
data: func() []byte {
h := make([]byte, 27)
copy(h, "OggS")
h[26] = 5 // expects 5 bytes of segment table, but none provided
return h
}(),
errContains: "failed to read segment table",
},
{
name: "eof in segment data",
data: func() []byte {
h := make([]byte, 27, 28)
copy(h, "OggS")
h[26] = 1
return append(h, 100) // expects 100 bytes of data, but none provided
}(),
errContains: "failed to read segment data",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := DecodeOggOpus(bytes.NewReader(tt.data), func(b []byte) error { return nil })
if tt.name == "short header" {
if err != nil {
t.Errorf("expected no error (io.EOF/ErrUnexpectedEOF swallowed), got %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.errContains)
}
if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("expected error to contain %q, got: %q", tt.errContains, err.Error())
}
})
}
}

96
pkg/audio/sentence.go Normal file
View file

@ -0,0 +1,96 @@
package audio
import (
"strings"
"unicode"
)
// SplitSentences splits text into sentence-sized chunks suitable for TTS synthesis.
// It splits on sentence-ending punctuation (.!?\n, as well as CJK 。, , ) while avoiding false splits
// on decimal numbers. Very short fragments are merged with
// the next sentence to prevent choppy playback.
func SplitSentences(text string) []string {
if text == "" {
return nil
}
var sentences []string
var current strings.Builder
runes := []rune(text)
for i := 0; i < len(runes); i++ {
r := runes[i]
if r == '\n' {
s := strings.TrimSpace(current.String())
if s != "" {
sentences = append(sentences, s)
}
current.Reset()
continue
}
current.WriteRune(r)
if r == '.' || r == '!' || r == '?' || r == '。' || r == '' || r == '' {
// Avoid splitting on decimal numbers like "3.14"
if r == '.' && i > 0 && unicode.IsDigit(runes[i-1]) &&
i+1 < len(runes) && unicode.IsDigit(runes[i+1]) {
continue
}
// Consume contiguous punctuation clusters (e.g., "..." or "?!").
for i+1 < len(runes) && (runes[i+1] == '.' || runes[i+1] == '!' || runes[i+1] == '?' || runes[i+1] == '。' || runes[i+1] == '' || runes[i+1] == '') {
i++
current.WriteRune(runes[i])
}
s := strings.TrimSpace(current.String())
if s != "" {
sentences = append(sentences, s)
}
current.Reset()
}
}
// Flush remaining text
if s := strings.TrimSpace(current.String()); s != "" {
sentences = append(sentences, s)
}
// Merge very short fragments with the next sentence
return mergeShorties(sentences, 15)
}
// mergeShorties merges sentences shorter than minLen characters with the following sentence.
func mergeShorties(sentences []string, minLen int) []string {
if len(sentences) <= 1 {
return sentences
}
var merged []string
var buf string
for _, s := range sentences {
if buf != "" {
buf += " " + s
if len([]rune(buf)) >= minLen {
merged = append(merged, buf)
buf = ""
}
} else if len([]rune(s)) < minLen {
buf = s
} else {
merged = append(merged, s)
}
}
if buf != "" {
if len(merged) > 0 {
merged[len(merged)-1] += " " + buf
} else {
merged = append(merged, buf)
}
}
return merged
}

View file

@ -0,0 +1,69 @@
package audio
import (
"reflect"
"testing"
)
func TestSplitSentences(t *testing.T) {
tests := []struct {
name string
in string
want []string
}{
{
name: "empty input",
in: "",
want: nil,
},
{
name: "single sentence",
in: "Hello world.",
want: []string{"Hello world."},
},
{
name: "decimal numbers do not split",
in: "The value is 3.14 today. Keep watching closely.",
want: []string{"The value is 3.14 today.", "Keep watching closely."},
},
{
name: "newline boundary",
in: "This is line number one\nThis is line number two",
want: []string{"This is line number one", "This is line number two"},
},
{
name: "newline with surrounding spaces",
in: " This is the first line \n This is the second line ",
want: []string{"This is the first line", "This is the second line"},
},
{
name: "trailing punctuation consumed",
in: "Please wait a moment... What on earth?! That is perfectly fine.",
want: []string{"Please wait a moment...", "What on earth?!", "That is perfectly fine."},
},
{
name: "short leading fragment merges with next",
in: "Hi. This is a longer sentence.",
want: []string{"Hi. This is a longer sentence."},
},
{
name: "consecutive short fragments keep merging",
in: "A. B. C. This is the real sentence.",
want: []string{"A. B. C. This is the real sentence."},
},
{
name: "short trailing fragment merges back",
in: "This sentence is long enough. End.",
want: []string{"This sentence is long enough. End."},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := SplitSentences(tc.in)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("SplitSentences(%q) = %#v, want %#v", tc.in, got, tc.want)
}
})
}
}

137
pkg/audio/tts/README.md Normal file
View file

@ -0,0 +1,137 @@
# TTS (Text-to-Speech)
This package handles speech synthesis for PicoClaw.
If you are new to TTS setup, the simplest workflow is:
1. Add a TTS-capable entry to `model_list`.
2. Point `voice.tts_model_name` at that entry.
3. Put the API key in `.security.yml`.
## Quick Recommendation
For most users, these are the best starting points:
| Provider | Why start here |
| --- | --- |
| [OpenAI](https://platform.openai.com/docs/guides/text-to-speech) | Best-supported path in PicoClaw today. The current TTS implementation is built around the OpenAI-compatible `/audio/speech` API shape, and OpenAI is the safest default. |
| [Xiaomi MiMo](https://platform.xiaomimimo.com) | A good second option if you want an OpenAI-compatible provider endpoint and are already using MiMo models in the rest of your stack. |
## How TTS Configuration Works
PicoClaw does not keep TTS API keys inside `voice`.
Instead:
- `voice.tts_model_name` selects a named entry from `model_list`.
- That `model_list` entry provides the provider, model ID, API base, and proxy settings.
- `.security.yml` stores the API key for the same named model entry.
This is the recommended and supported configuration pattern.
## Recommended Setup
### Option A: OpenAI
`config.json`
```json
{
"voice": {
"tts_model_name": "openai-tts"
},
"model_list": [
{
"model_name": "openai-tts",
"model": "openai/tts-1"
}
]
}
```
`.security.yml`
```yaml
model_list:
openai-tts:
api_keys:
- "sk-openai-your-key"
```
### Option B: Xiaomi MiMo
`config.json`
```json
{
"voice": {
"tts_model_name": "mimo-tts"
},
"model_list": [
{
"model_name": "mimo-tts",
"model": "mimo/mimo-v2-tts"
}
]
}
```
`.security.yml`
```yaml
model_list:
mimo-tts:
api_keys:
- "your-mimo-key"
```
If you use a custom MiMo endpoint, you can also set `api_base` explicitly. Otherwise PicoClaw will use the provider default.
## What PicoClaw Sends Today
The current TTS runtime uses an OpenAI-compatible speech request with these defaults:
- Endpoint: `/audio/speech`
- Response format: `opus`
- Voice: `alloy`
- Model: taken from the selected `model_list` entry
That means:
- `openai/tts-1` works naturally.
- Other OpenAI-compatible providers can work if they accept the same request format.
- PicoClaw currently does not expose a user-facing config field for changing the TTS voice from `alloy`.
## How PicoClaw Chooses a TTS Provider
`DetectTTS` resolves TTS in this order:
1. **Preferred path**: resolve `voice.tts_model_name` against `model_list`.
2. If a matching model entry exists and has an API key, PicoClaw creates an OpenAI-compatible TTS provider using that model's settings.
3. **Fallback path**: if `voice.tts_model_name` is not set or cannot be resolved, PicoClaw scans `model_list` for the first entry whose model string contains `tts` and has an API key.
Fallback scanning exists for compatibility. New configs should set `voice.tts_model_name` explicitly.
## Notes About API Base Handling
PicoClaw normalizes the configured base URL for TTS:
- For OpenAI, a base like `https://api.openai.com` or `https://api.openai.com/v1` becomes `https://api.openai.com/v1/audio/speech`.
- For other OpenAI-compatible providers, PicoClaw preserves the configured base path and ensures it ends with `/audio/speech`.
- If `api_base` is omitted, PicoClaw uses the provider default base when the model prefix is known.
## Common Mistakes
- Setting `voice.tts_model_name` to a name that does not exist in `model_list`.
- Adding a TTS model but forgetting to put its API key in `.security.yml`.
- Assuming PicoClaw will automatically use provider-specific custom voices.
- Using a provider endpoint that is not compatible with the OpenAI `/audio/speech` request format.
## Minimal Checklist
Before testing `send_tts`, make sure:
- `voice.tts_model_name` matches a `model_list[].model_name`.
- The matching `.security.yml` entry contains a valid API key.
- The chosen provider supports an OpenAI-compatible speech synthesis endpoint.
- Your selected model is actually a TTS-capable model.

137
pkg/audio/tts/README_zh.md Normal file
View file

@ -0,0 +1,137 @@
# TTS文本转语音
这个目录负责 PicoClaw 的语音合成能力。
如果你是第一次配置 TTS可以参照下面这个流程
1. 在 `model_list` 里添加一个支持 TTS 的模型。
2. 用 `voice.tts_model_name` 指向这个模型。
3. 在 `.security.yml` 里配置对应的 API Key。
## 快速推荐
对于大多数用户,建议优先从下面两种开始:
| 提供商 | 推荐理由 |
| --- | --- |
| [OpenAI](https://platform.openai.com/docs/guides/text-to-speech) | 这是 PicoClaw 当前最稳定、最直接的 TTS 路径。当前实现就是围绕 OpenAI 兼容的 `/audio/speech` 接口格式构建的,所以 OpenAI 是最稳妥的默认选择。 |
| [Xiaomi MiMo](https://platform.xiaomimimo.com) | 由于响应速度和语音音色对于中国用户更友好MiMo 是一个不错的第二选择。 |
## TTS 配置是如何工作的
PicoClaw 不会把 TTS 的 API Key 放在 `voice` 配置里。
推荐方式是:
- `voice.tts_model_name` 用来选择 `model_list` 里的某个命名模型。
- 对应的 `model_list` 条目提供真实的 provider、model ID、`api_base` 和代理配置。
- `.security.yml` 负责保存该模型条目的 API Key。
这是当前推荐且受支持的配置方式。
## 推荐配置方式
### 方案 AOpenAI
`config.json`
```json
{
"voice": {
"tts_model_name": "openai-tts"
},
"model_list": [
{
"model_name": "openai-tts",
"model": "openai/tts-1"
}
]
}
```
`.security.yml`
```yaml
model_list:
openai-tts:
api_keys:
- "sk-openai-your-key"
```
### 方案 BXiaomi MiMo
`config.json`
```json
{
"voice": {
"tts_model_name": "mimo-tts"
},
"model_list": [
{
"model_name": "mimo-tts",
"model": "mimo/mimo-v2-tts"
}
]
}
```
`.security.yml`
```yaml
model_list:
mimo-tts:
api_keys:
- "your-mimo-key"
```
如果你使用自定义的 MiMo 接口地址,也可以显式设置 `api_base`。如果不设置PicoClaw 会自动使用该 provider 的默认地址。
## PicoClaw 当前实际发送的 TTS 请求
当前 TTS 运行时使用的是 OpenAI 兼容的语音合成请求,并带有以下默认值:
- Endpoint`/audio/speech`
- 返回格式:`opus`
- Voice`alloy`
- Model来自你所选中的 `model_list` 条目
这意味着:
- `openai/tts-1` 可以自然工作。
- 其他 OpenAI 兼容 provider 也可能可用,前提是它们接受相同的请求格式。
- PicoClaw 目前还没有对用户暴露一个配置项来修改 TTS voice当前固定为 `alloy`
## PicoClaw 如何选择 TTS Provider
`DetectTTS` 会按下面顺序选择 TTS
1. **首选路径**:根据 `voice.tts_model_name``model_list` 中找到对应模型。
2. 如果找到了匹配条目,并且它有 API KeyPicoClaw 就会使用这个模型条目的配置创建一个 OpenAI 兼容的 TTS provider。
3. **回退路径**:如果没有设置 `voice.tts_model_name`或者该名字无法解析PicoClaw 会扫描 `model_list`,选中第一个模型字符串里包含 `tts` 且带有 API Key 的条目。
回退扫描只是为了兼容旧行为。新配置建议始终显式设置 `voice.tts_model_name`
## 关于 API Base 的处理方式
PicoClaw 会对 TTS 的 `api_base` 做规范化处理:
- 对 OpenAI 来说,像 `https://api.openai.com``https://api.openai.com/v1` 这样的地址,会自动变成 `https://api.openai.com/v1/audio/speech`
- 对其他 OpenAI 兼容 providerPicoClaw 会尽量保留你提供的基础路径,只确保它最终以 `/audio/speech` 结尾。
- 如果没有设置 `api_base`,并且模型前缀是已知 providerPicoClaw 会自动使用该 provider 的默认地址。
## 常见错误
- `voice.tts_model_name` 指向了一个不存在的 `model_list` 名称。
- 在 `model_list` 里定义了 TTS 模型,但忘了在 `.security.yml` 中配置对应 API Key。
- 误以为 PicoClaw 会自动支持 provider 自定义 voice 参数。
- 使用了不兼容 OpenAI `/audio/speech` 请求格式的接口地址。
## 最小检查清单
在测试 `send_tts` 之前,请确认:
- `voice.tts_model_name` 能正确匹配某个 `model_list[].model_name`
- `.security.yml` 中对应条目已经配置了有效 API Key。
- 你所选的 provider 支持 OpenAI 兼容的语音合成接口。
- 你选择的模型本身确实支持 TTS。

162
pkg/audio/tts/mimo_tts.go Normal file
View file

@ -0,0 +1,162 @@
package tts
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
)
type MimoTTSProvider struct {
apiKey string
apiBase string
voice string
format string
model string
httpClient *http.Client
}
func NewMimoTTSProvider(apiKey string, apiBase string, model string, proxyURL string) *MimoTTSProvider {
if apiBase == "" {
apiBase = "https://api.xiaomimimo.com/v1/chat/completions"
} else {
if u, err := url.Parse(apiBase); err == nil && u.Scheme != "" && u.Host != "" {
path := u.Path
if u.Host == "api.xiaomimimo.com" {
if path == "" || path == "/" || path == "/v1" || path == "/v1/" {
path = "/v1/chat/completions"
} else {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if !strings.HasPrefix(path, "/v1/") {
path = "/v1" + strings.TrimSuffix(path, "/")
}
if !strings.HasSuffix(path, "/chat/completions") {
path = strings.TrimSuffix(path, "/") + "/chat/completions"
}
}
} else {
if !strings.HasSuffix(path, "/chat/completions") {
path = strings.TrimSuffix(path, "/") + "/chat/completions"
}
}
u.Path = path
apiBase = u.String()
} else {
if apiBase == "https://api.xiaomimimo.com/v1" {
apiBase = "https://api.xiaomimimo.com/v1/chat/completions"
} else if !strings.HasSuffix(apiBase, "/chat/completions") {
apiBase = strings.TrimSuffix(apiBase, "/") + "/chat/completions"
}
}
}
model = strings.TrimSpace(model)
if model == "" {
model = "mimo-v2-tts"
}
client := &http.Client{Timeout: 60 * time.Second}
if proxyURL != "" {
if pURL, err := url.Parse(proxyURL); err == nil {
client.Transport = &http.Transport{Proxy: http.ProxyURL(pURL)}
} else {
logger.WarnF(
"NewMimoTTSProvider: invalid proxy URL; proceeding without proxy",
map[string]any{"proxyURL": proxyURL, "error": err},
)
}
}
return &MimoTTSProvider{
apiKey: apiKey,
apiBase: apiBase,
voice: "default_zh", // mimo_default now seems to be an alias for default_en, which is not working for Chinese TTS. default_zh seems to work fine with both English and Chinese, and is likely the intended default for TTS.
format: "mp3",
model: model,
httpClient: client,
}
}
func (t *MimoTTSProvider) Name() string {
return "mimo-tts"
}
func (t *MimoTTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) {
logger.DebugCF("voice-tts", "Starting TTS synthesis", map[string]any{"text_len": len(text), "provider": t.Name()})
reqBody := map[string]any{
"model": t.model,
"messages": []map[string]string{
{"role": "assistant", "content": text},
},
"audio": map[string]string{
"format": t.format,
"voice": t.voice,
},
"stream": false,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", t.apiBase, 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("Api-Key", t.apiKey)
resp, err := t.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
var payload struct {
Choices []struct {
Message struct {
Audio struct {
Data string `json:"data"`
} `json:"audio"`
} `json:"message"`
} `json:"choices"`
}
err = json.Unmarshal(body, &payload)
if err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if len(payload.Choices) == 0 || payload.Choices[0].Message.Audio.Data == "" {
return nil, fmt.Errorf("invalid TTS response: missing audio data")
}
audioBytes, err := base64.StdEncoding.DecodeString(payload.Choices[0].Message.Audio.Data)
if err != nil {
return nil, fmt.Errorf("failed to decode audio data: %w", err)
}
return io.NopCloser(bytes.NewReader(audioBytes)), nil
}

126
pkg/audio/tts/openai_tts.go Normal file
View file

@ -0,0 +1,126 @@
package tts
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers/common"
)
type OpenAITTSProvider struct {
apiKey string
apiBase string
voice string
model string
httpClient *http.Client
}
func NewOpenAITTSProvider(apiKey string, apiBase string, proxyURL string, model string) *OpenAITTSProvider {
// Normalize apiBase to avoid malformed endpoints like
// "https://api.openai.com/audio/speech" when "/v1" is required.
if apiBase == "" {
apiBase = "https://api.openai.com/v1/audio/speech"
} else {
if u, err := url.Parse(apiBase); err == nil && u.Scheme != "" && u.Host != "" {
path := u.Path
if u.Host == "api.openai.com" {
// For the official OpenAI host, ensure exactly one /v1 prefix and
// that the path ends with /audio/speech.
if path == "" || path == "/" || path == "/v1" {
path = "/v1/audio/speech"
} else {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if !strings.HasPrefix(path, "/v1/") {
path = "/v1" + strings.TrimSuffix(path, "/")
}
if !strings.HasSuffix(path, "/audio/speech") {
path = strings.TrimSuffix(path, "/") + "/audio/speech"
}
}
} else {
// For non-OpenAI hosts (e.g., proxies), preserve the existing base
// path and only ensure it ends with /audio/speech.
if !strings.HasSuffix(path, "/audio/speech") {
path = strings.TrimSuffix(path, "/") + "/audio/speech"
}
}
u.Path = path
apiBase = u.String()
} else {
// Fallback to the previous string-based behavior if parsing fails.
if apiBase == "https://api.openai.com/v1" {
apiBase = "https://api.openai.com/v1/audio/speech"
} else if !strings.HasSuffix(apiBase, "/audio/speech") {
// Just in case they provide openrouter base or standard base
apiBase = strings.TrimSuffix(apiBase, "/") + "/audio/speech"
}
}
}
client := common.NewHTTPClient(proxyURL)
client.Timeout = 60 * time.Second
model = strings.TrimSpace(model)
if model == "" {
model = "tts-1"
}
return &OpenAITTSProvider{
apiKey: apiKey,
apiBase: apiBase,
voice: "alloy",
model: model,
httpClient: client,
}
}
func (t *OpenAITTSProvider) Name() string {
return "openai-tts"
}
func (t *OpenAITTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) {
logger.DebugCF("voice-tts", "Starting TTS synthesis", map[string]any{"text_len": len(text)})
reqBody := map[string]any{
"model": t.model,
"input": text,
"voice": t.voice,
"response_format": "opus",
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", t.apiBase, 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("Authorization", "Bearer "+t.apiKey)
resp, err := t.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
return resp.Body, nil
}

151
pkg/audio/tts/tts.go Normal file
View file

@ -0,0 +1,151 @@
package tts
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers"
)
type TTSProvider interface {
Name() string
Synthesize(ctx context.Context, text string) (io.ReadCloser, error)
}
func providerFromModelConfig(mc *config.ModelConfig) TTSProvider {
if mc == nil || mc.APIKey() == "" {
return nil
}
protocol, modelID := providers.ExtractProtocol(mc.Model)
if modelID == "" {
modelID = strings.TrimSpace(mc.Model)
}
switch protocol {
case "mimo":
return NewMimoTTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), modelID, mc.Proxy)
default:
return NewOpenAITTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), mc.Proxy, modelID)
}
}
func DetectTTS(cfg *config.Config) TTSProvider {
if cfg == nil {
return nil
}
if modelName := strings.TrimSpace(cfg.Voice.TTSModelName); modelName != "" {
if mc, err := cfg.GetModelConfig(modelName); err == nil {
if provider := providerFromModelConfig(mc); provider != nil {
return provider
}
}
}
for _, mc := range cfg.ModelList {
if strings.Contains(strings.ToLower(mc.Model), "tts") && mc.APIKey() != "" {
if provider := providerFromModelConfig(mc); provider != nil {
return provider
}
}
}
return nil
}
// SynthesizeAndStore synthesizes text to speech and registers it in the media store, returning the media reference.
func SynthesizeAndStore(
ctx context.Context,
provider TTSProvider,
store media.MediaStore,
text string,
filename string,
channel string,
chatID string,
) (string, error) {
if provider == nil {
return "", fmt.Errorf("tts provider is not configured")
}
if store == nil {
return "", fmt.Errorf("media store not configured")
}
if channel == "" || chatID == "" {
return "", fmt.Errorf("no target channel/chat available")
}
if strings.TrimSpace(text) == "" {
return "", fmt.Errorf("text is required")
}
stream, err := provider.Synthesize(ctx, text)
if err != nil {
return "", fmt.Errorf("tts synthesize failed: %w", err)
}
defer stream.Close()
err = os.MkdirAll(media.TempDir(), 0o700)
if err != nil {
return "", fmt.Errorf("failed to create media temp dir: %w", err)
}
fileExt := ".ogg"
contentType := "audio/ogg"
if provider.Name() == "mimo-tts" {
fileExt = ".mp3"
contentType = "audio/mpeg"
}
file, err := os.CreateTemp(media.TempDir(), "tts-*"+fileExt)
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
removeTemp := true
defer func() {
if removeTemp {
_ = os.Remove(file.Name())
}
}()
_, err = io.Copy(file, stream)
if err != nil {
file.Close()
return "", fmt.Errorf("failed to write tts audio: %w", err)
}
err = file.Close()
if err != nil {
return "", fmt.Errorf("failed to close tts audio file: %w", err)
}
filename = strings.TrimSpace(filename)
if filename == "" {
filename = fmt.Sprintf("tts-%d%s", time.Now().Unix(), fileExt)
}
ext := strings.ToLower(filepath.Ext(filename))
if ext == "" {
filename += fileExt
} else if ext != fileExt {
filename = strings.TrimSuffix(filename, filepath.Ext(filename)) + fileExt
}
scope := fmt.Sprintf("tool:send_tts:%s:%s:%d", channel, chatID, time.Now().UnixNano())
ref, err := store.Store(file.Name(), media.MediaMeta{
Filename: filename,
ContentType: contentType,
Source: "tool:send_tts",
}, scope)
if err != nil {
return "", fmt.Errorf("failed to register audio: %w", err)
}
removeTemp = false
return ref, nil
}

247
pkg/audio/tts/tts_test.go Normal file
View file

@ -0,0 +1,247 @@
package tts
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/media"
)
func TestNewOpenAITTSProvider_APIBaseNormalization(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
expect string
}{
{
name: "empty base",
input: "",
expect: "https://api.openai.com/v1/audio/speech",
},
{
name: "official host no path",
input: "https://api.openai.com",
expect: "https://api.openai.com/v1/audio/speech",
},
{
name: "official host v1",
input: "https://api.openai.com/v1",
expect: "https://api.openai.com/v1/audio/speech",
},
{
name: "official host v1 slash",
input: "https://api.openai.com/v1/",
expect: "https://api.openai.com/v1/audio/speech",
},
{
name: "non-openai host preserves base path",
input: "https://proxy.example.com/base",
expect: "https://proxy.example.com/base/audio/speech",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
provider := NewOpenAITTSProvider("key", tc.input, "", "")
if provider.apiBase != tc.expect {
t.Fatalf("apiBase mismatch: got %q, want %q", provider.apiBase, tc.expect)
}
})
}
}
func TestOpenAITTSProvider_SynthesizeSuccess(t *testing.T) {
t.Parallel()
var gotPath string
var gotAuth string
var gotContentType string
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
gotContentType = r.Header.Get("Content-Type")
bodyBytes, _ := io.ReadAll(r.Body)
_ = r.Body.Close()
_ = json.Unmarshal(bodyBytes, &gotBody)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("audio-bytes"))
}))
defer server.Close()
provider := NewOpenAITTSProvider("k123", server.URL, "", "")
stream, err := provider.Synthesize(context.Background(), "hello")
if err != nil {
t.Fatalf("Synthesize failed: %v", err)
}
defer stream.Close()
data, err := io.ReadAll(stream)
if err != nil {
t.Fatalf("read stream failed: %v", err)
}
if gotPath != "/audio/speech" {
t.Fatalf("request path mismatch: got %q", gotPath)
}
if gotAuth != "Bearer k123" {
t.Fatalf("authorization mismatch: got %q", gotAuth)
}
if gotContentType != "application/json" {
t.Fatalf("content-type mismatch: got %q", gotContentType)
}
if gotBody["model"] != "tts-1" || gotBody["voice"] != "alloy" || gotBody["response_format"] != "opus" ||
gotBody["input"] != "hello" {
bodyJSON, _ := json.Marshal(gotBody)
t.Fatalf("request body mismatch: %s", string(bodyJSON))
}
if string(data) != "audio-bytes" {
t.Fatalf("response body mismatch: got %q", string(data))
}
}
func TestOpenAITTSProvider_SynthesizeNon200(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("nope"))
}))
defer server.Close()
provider := NewOpenAITTSProvider("k123", server.URL, "", "")
_, err := provider.Synthesize(context.Background(), "hello")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "API error (status 500): nope") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestNewOpenAITTSProvider_UsesConfiguredModel(t *testing.T) {
t.Parallel()
provider := NewOpenAITTSProvider("key", "https://api.xiaomimimo.com/v1", "", "mimo-v2-tts")
if provider.model != "mimo-v2-tts" {
t.Fatalf("model mismatch: got %q, want %q", provider.model, "mimo-v2-tts")
}
if provider.apiBase != "https://api.xiaomimimo.com/v1/audio/speech" {
t.Fatalf("apiBase mismatch: got %q", provider.apiBase)
}
}
func TestDetectTTS_UsesMimoProviderForMimoModels(t *testing.T) {
t.Parallel()
provider := DetectTTS(&config.Config{
Voice: config.VoiceConfig{TTSModelName: "mimo-tts"},
ModelList: []*config.ModelConfig{
{
ModelName: "mimo-tts",
Model: "mimo/mimo-v2-tts",
APIKeys: config.SimpleSecureStrings("sk-mimo"),
},
},
})
ttsProvider, ok := provider.(*MimoTTSProvider)
if !ok {
t.Fatalf("DetectTTS() type = %T, want *MimoTTSProvider", provider)
}
if ttsProvider.model != "mimo-v2-tts" {
t.Fatalf("model mismatch: got %q, want %q", ttsProvider.model, "mimo-v2-tts")
}
if ttsProvider.apiBase != "https://api.xiaomimimo.com/v1/chat/completions" {
t.Fatalf("apiBase mismatch: got %q", ttsProvider.apiBase)
}
}
type stubTTSProvider struct {
name string
}
func (s stubTTSProvider) Name() string {
return s.name
}
func (s stubTTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("audio")), nil
}
func TestSynthesizeAndStore_UsesOggMetadataByDefault(t *testing.T) {
t.Parallel()
store := media.NewFileMediaStore()
ref, err := SynthesizeAndStore(
context.Background(),
stubTTSProvider{name: "openai-tts"},
store,
"hello",
"",
"discord",
"chat123",
)
if err != nil {
t.Fatalf("SynthesizeAndStore failed: %v", err)
}
path, meta, err := store.ResolveWithMeta(ref)
if err != nil {
t.Fatalf("ResolveWithMeta failed: %v", err)
}
if meta.ContentType != "audio/ogg" {
t.Fatalf("ContentType = %q, want %q", meta.ContentType, "audio/ogg")
}
if filepath.Ext(path) != ".ogg" {
t.Fatalf("stored file extension = %q, want %q", filepath.Ext(path), ".ogg")
}
if filepath.Ext(meta.Filename) != ".ogg" {
t.Fatalf("filename extension = %q, want %q", filepath.Ext(meta.Filename), ".ogg")
}
}
func TestSynthesizeAndStore_UsesMp3MetadataForMimo(t *testing.T) {
t.Parallel()
store := media.NewFileMediaStore()
ref, err := SynthesizeAndStore(
context.Background(),
stubTTSProvider{name: "mimo-tts"},
store,
"hello",
"",
"discord",
"chat123",
)
if err != nil {
t.Fatalf("SynthesizeAndStore failed: %v", err)
}
path, meta, err := store.ResolveWithMeta(ref)
if err != nil {
t.Fatalf("ResolveWithMeta failed: %v", err)
}
if meta.ContentType != "audio/mpeg" {
t.Fatalf("ContentType = %q, want %q", meta.ContentType, "audio/mpeg")
}
if filepath.Ext(path) != ".mp3" {
t.Fatalf("stored file extension = %q, want %q", filepath.Ext(path), ".mp3")
}
if filepath.Ext(meta.Filename) != ".mp3" {
t.Fatalf("filename extension = %q, want %q", filepath.Ext(meta.Filename), ".mp3")
}
}

View file

@ -34,6 +34,8 @@ type MessageBus struct {
inbound chan InboundMessage inbound chan InboundMessage
outbound chan OutboundMessage outbound chan OutboundMessage
outboundMedia chan OutboundMediaMessage outboundMedia chan OutboundMediaMessage
audioChunks chan AudioChunk
voiceControls chan VoiceControl
closeOnce sync.Once closeOnce sync.Once
done chan struct{} done chan struct{}
@ -47,6 +49,8 @@ func NewMessageBus() *MessageBus {
inbound: make(chan InboundMessage, defaultBusBufferSize), inbound: make(chan InboundMessage, defaultBusBufferSize),
outbound: make(chan OutboundMessage, defaultBusBufferSize), outbound: make(chan OutboundMessage, defaultBusBufferSize),
outboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize), outboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize),
audioChunks: make(chan AudioChunk, defaultBusBufferSize*4), // Audio chunks need more buffer
voiceControls: make(chan VoiceControl, defaultBusBufferSize),
done: make(chan struct{}), done: make(chan struct{}),
} }
} }
@ -103,6 +107,22 @@ func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage {
return mb.outboundMedia return mb.outboundMedia
} }
func (mb *MessageBus) PublishAudioChunk(ctx context.Context, chunk AudioChunk) error {
return publish(ctx, mb, mb.audioChunks, chunk)
}
func (mb *MessageBus) AudioChunksChan() <-chan AudioChunk {
return mb.audioChunks
}
func (mb *MessageBus) PublishVoiceControl(ctx context.Context, ctrl VoiceControl) error {
return publish(ctx, mb, mb.voiceControls, ctrl)
}
func (mb *MessageBus) VoiceControlsChan() <-chan VoiceControl {
return mb.voiceControls
}
// SetStreamDelegate registers a StreamDelegate (typically the channel Manager). // SetStreamDelegate registers a StreamDelegate (typically the channel Manager).
func (mb *MessageBus) SetStreamDelegate(d StreamDelegate) { func (mb *MessageBus) SetStreamDelegate(d StreamDelegate) {
mb.streamDelegate.Store(d) mb.streamDelegate.Store(d)
@ -132,6 +152,8 @@ func (mb *MessageBus) Close() {
close(mb.inbound) close(mb.inbound)
close(mb.outbound) close(mb.outbound)
close(mb.outboundMedia) close(mb.outboundMedia)
close(mb.audioChunks)
close(mb.voiceControls)
// clean up any remaining messages in channels // clean up any remaining messages in channels
drained := 0 drained := 0
@ -144,6 +166,12 @@ func (mb *MessageBus) Close() {
for range mb.outboundMedia { for range mb.outboundMedia {
drained++ drained++
} }
for range mb.audioChunks {
drained++
}
for range mb.voiceControls {
drained++
}
if drained > 0 { if drained > 0 {
logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{ logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{

View file

@ -34,6 +34,7 @@ type OutboundMessage struct {
ChatID string `json:"chat_id"` ChatID string `json:"chat_id"`
Content string `json:"content"` Content string `json:"content"`
ReplyToMessageID string `json:"reply_to_message_id,omitempty"` ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
} }
// MediaPart describes a single media attachment to send. // MediaPart describes a single media attachment to send.
@ -51,3 +52,25 @@ type OutboundMediaMessage struct {
ChatID string `json:"chat_id"` ChatID string `json:"chat_id"`
Parts []MediaPart `json:"parts"` Parts []MediaPart `json:"parts"`
} }
// AudioChunk represents a chunk of streaming voice data.
type AudioChunk struct {
SessionID string `json:"session_id"`
SpeakerID string `json:"speaker_id"` // User ID or SSRC
ChatID string `json:"chat_id"` // Where to respond
Channel string `json:"channel"` // Source channel type (e.g. "discord")
Sequence uint64 `json:"sequence"`
Timestamp uint32 `json:"timestamp"`
SampleRate int `json:"sample_rate"`
Channels int `json:"channels"`
Format string `json:"format"` // "opus", "pcm", etc
Data []byte `json:"data"`
}
// VoiceControl represents state or commands for voice sessions.
type VoiceControl struct {
SessionID string `json:"session_id"`
ChatID string `json:"chat_id"`
Type string `json:"type"` // "state", "command"
Action string `json:"action"` // "idle", "listening", "start", "stop", "leave"
}

View file

@ -3,6 +3,7 @@ package discord
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@ -14,6 +15,8 @@ import (
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/audio"
"github.com/sipeed/picoclaw/pkg/audio/tts"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
@ -42,6 +45,15 @@ type DiscordChannel struct {
typingMu sync.Mutex typingMu sync.Mutex
typingStop map[string]chan struct{} // chatID → stop signal typingStop map[string]chan struct{} // chatID → stop signal
botUserID string // stored for mention checking botUserID string // stored for mention checking
bus *bus.MessageBus
tts tts.TTSProvider
voiceMu sync.RWMutex
voiceSSRC map[string]map[uint32]string // guildID -> ssrc -> userID
// TTS interruption: cancel active playback when user speaks
ttsMu sync.Mutex
cancelTTS context.CancelFunc
ttsPlayID uint64
} }
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
@ -73,6 +85,8 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
config: cfg, config: cfg,
ctx: context.Background(), ctx: context.Background(),
typingStop: make(map[string]chan struct{}), typingStop: make(map[string]chan struct{}),
bus: bus,
voiceSSRC: make(map[string]map[uint32]string),
}, nil }, nil
} }
@ -90,6 +104,8 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
c.session.AddHandler(c.handleMessage) c.session.AddHandler(c.handleMessage)
go c.listenVoiceControl(c.ctx)
if err := c.session.Open(); err != nil { if err := c.session.Open(); err != nil {
return fmt.Errorf("failed to open discord session: %w", err) return fmt.Errorf("failed to open discord session: %w", err)
} }
@ -142,6 +158,25 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]s
return nil, nil return nil, nil
} }
if c.tts != nil {
if ch, err := c.session.State.Channel(channelID); err == nil && ch.GuildID != "" {
if vc, ok := c.session.VoiceConnections[ch.GuildID]; ok && vc != nil {
// Cancel any previous TTS playback
c.ttsMu.Lock()
if c.cancelTTS != nil {
c.cancelTTS()
}
ttsCtx, ttsCancel := context.WithCancel(c.ctx)
c.ttsPlayID++
playID := c.ttsPlayID
c.cancelTTS = ttsCancel
c.ttsMu.Unlock()
go c.playTTS(ttsCtx, vc, msg.Content, playID)
}
}
}
msgID, err := c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) msgID, err := c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -359,6 +394,10 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
return return
} }
if c.handleVoiceCommand(s, m) {
return
}
content := m.Content content := m.Content
// In guild (group) channels, apply unified group trigger filtering // In guild (group) channels, apply unified group trigger filtering
@ -630,3 +669,134 @@ func (c *DiscordChannel) stripBotMention(text string) string {
text = strings.ReplaceAll(text, fmt.Sprintf("<@!%s>", c.botUserID), "") text = strings.ReplaceAll(text, fmt.Sprintf("<@!%s>", c.botUserID), "")
return strings.TrimSpace(text) return strings.TrimSpace(text)
} }
func (c *DiscordChannel) listenVoiceControl(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case ctrl, ok := <-c.bus.VoiceControlsChan():
if !ok {
return
}
if ctrl.Type == "command" && ctrl.Action == "leave" {
if strings.HasPrefix(ctrl.SessionID, "discord_vc_") {
guildID := strings.TrimPrefix(ctrl.SessionID, "discord_vc_")
vc, exists := c.session.VoiceConnections[guildID]
if exists && vc != nil {
vc.Disconnect(ctx)
}
}
}
}
}
}
func (c *DiscordChannel) playTTS(ctx context.Context, vc *discordgo.VoiceConnection, text string, playID uint64) {
// Capture the cancel func associated with this playback (if any).
// Clear cancelTTS when playback finishes (normal or interrupted),
// but only if it still refers to this playback's cancel func.
defer func() {
c.ttsMu.Lock()
if c.ttsPlayID == playID {
c.cancelTTS = nil
}
c.ttsMu.Unlock()
}()
sentences := audio.SplitSentences(text)
if len(sentences) == 0 {
return
}
logger.InfoCF("discord", "Starting streamed TTS", map[string]any{"sentences": len(sentences)})
// Pipeline: prefetch next sentence's audio while playing current
type ttResult struct {
stream io.ReadCloser
err error
}
var prefetch chan ttResult
// Ensure any in-flight prefetch is drained on exit to prevent stream leaks,
// but avoid blocking indefinitely if the prefetch goroutine is stuck or never sends.
defer func() {
if prefetch != nil {
select {
case result := <-prefetch:
if result.stream != nil {
result.stream.Close()
}
case <-time.After(100 * time.Millisecond):
// Timed out waiting for a prefetched result; avoid blocking on exit.
}
}
}()
for i, sentence := range sentences {
// Check for cancellation (interruption)
select {
case <-ctx.Done():
logger.InfoCF("discord", "TTS interrupted", map[string]any{"at_sentence": i})
return
default:
}
// Start prefetching the NEXT sentence while we process the current one
var nextPrefetch chan ttResult
if i+1 < len(sentences) {
nextPrefetch = make(chan ttResult, 1)
nextSentence := sentences[i+1]
go func() {
s, e := c.tts.Synthesize(ctx, nextSentence)
nextPrefetch <- ttResult{s, e}
}()
}
// Get the current sentence's audio
var stream io.ReadCloser
var err error
if prefetch != nil {
// Use prefetched result from previous iteration, but be responsive to cancellation.
var result ttResult
select {
case result = <-prefetch:
stream, err = result.stream, result.err
case <-ctx.Done():
// Context canceled while waiting for prefetched audio; abort playback.
logger.InfoCF(
"discord",
"TTS interrupted while waiting for prefetched audio",
map[string]any{"at_sentence": i},
)
return
}
} else {
// First sentence: synthesize directly
stream, err = c.tts.Synthesize(ctx, sentence)
}
if err != nil {
if stream != nil {
stream.Close()
}
logger.ErrorCF("discord", "TTS synthesize failed", map[string]any{"error": err.Error(), "sentence": i})
prefetch = nextPrefetch
continue
}
if err := streamOggOpusToDiscord(ctx, vc, stream); err != nil {
logger.ErrorCF("discord", "TTS playback failed", map[string]any{"error": err.Error(), "sentence": i})
}
stream.Close()
prefetch = nextPrefetch
}
}
// VoiceCapabilities returns the voice capabilities of the channel.
func (c *DiscordChannel) VoiceCapabilities() channels.VoiceCapabilities {
return channels.VoiceCapabilities{ASR: true, TTS: true}
}

View file

@ -1,6 +1,7 @@
package discord package discord
import ( import (
"github.com/sipeed/picoclaw/pkg/audio/tts"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
@ -8,6 +9,10 @@ import (
func init() { func init() {
channels.RegisterFactory("discord", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { channels.RegisterFactory("discord", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewDiscordChannel(cfg.Channels.Discord, b) ch, err := NewDiscordChannel(cfg.Channels.Discord, b)
if err == nil {
ch.tts = tts.DetectTTS(cfg)
}
return ch, err
}) })
} }

View file

@ -0,0 +1,313 @@
package discord
import (
"context"
"fmt"
"io"
"time"
"github.com/bwmarrin/discordgo"
"github.com/sipeed/picoclaw/pkg/audio"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
)
func (c *DiscordChannel) setVoiceUserID(guildID string, ssrc uint32, userID string) {
if userID == "" {
return
}
c.voiceMu.Lock()
defer c.voiceMu.Unlock()
ssrcMap, ok := c.voiceSSRC[guildID]
if !ok {
ssrcMap = make(map[uint32]string)
c.voiceSSRC[guildID] = ssrcMap
}
ssrcMap[ssrc] = userID
}
func (c *DiscordChannel) voiceUserID(guildID string, ssrc uint32) string {
c.voiceMu.RLock()
defer c.voiceMu.RUnlock()
ssrcMap, ok := c.voiceSSRC[guildID]
if !ok {
return ""
}
return ssrcMap[ssrc]
}
func (c *DiscordChannel) handleVoiceCommand(s *discordgo.Session, m *discordgo.MessageCreate) bool {
if m.Content == "!vc join" {
vs, err := s.State.VoiceState(m.GuildID, m.Author.ID)
if err != nil || vs == nil {
if _, sendErr := s.ChannelMessageSend(
m.ChannelID,
"You need to be in a voice channel first!",
); sendErr != nil {
logger.InfoCF("discord", "Failed to send voice channel requirement message", map[string]any{
"channel": m.ChannelID,
"error": sendErr,
})
}
return true
}
logger.InfoCF("discord", "Joining voice channel", map[string]any{"channel": vs.ChannelID})
vc, err := s.ChannelVoiceJoin(c.ctx, m.GuildID, vs.ChannelID, false, false)
if err != nil {
if _, sendErr := s.ChannelMessageSend(
m.ChannelID,
fmt.Sprintf("Failed to join voice channel: %v", err),
); sendErr != nil {
logger.InfoCF("discord", "Failed to send voice join error message", map[string]any{
"channel": m.ChannelID,
"error": sendErr,
})
}
return true
}
go c.receiveVoice(vc, m.GuildID, m.ChannelID)
if _, sendErr := s.ChannelMessageSend(
m.ChannelID,
"Joined Voice Channel! Listening for audio...",
); sendErr != nil {
logger.InfoCF("discord", "Failed to send voice join success message", map[string]any{
"channel": m.ChannelID,
"error": sendErr,
})
}
return true
} else if m.Content == "!vc leave" {
vc, exists := s.VoiceConnections[m.GuildID]
if exists && vc != nil {
if err := vc.Disconnect(c.ctx); err != nil {
logger.InfoCF("discord", "Failed to disconnect from voice channel", map[string]any{
"guild": m.GuildID,
"error": err,
})
}
if _, sendErr := s.ChannelMessageSend(m.ChannelID, "Left Voice Channel."); sendErr != nil {
logger.InfoCF("discord", "Failed to send voice leave success message", map[string]any{
"channel": m.ChannelID,
"error": sendErr,
})
}
} else {
if _, sendErr := s.ChannelMessageSend(m.ChannelID, "Not in a voice channel."); sendErr != nil {
logger.InfoCF("discord", "Failed to send voice not-in-channel message", map[string]any{
"channel": m.ChannelID,
"error": sendErr,
})
}
}
return true
}
return false
}
func VoiceReceiveActive(vc *discordgo.VoiceConnection) bool {
return vc != nil && vc.OpusRecv != nil
}
func streamOggOpusToDiscord(ctx context.Context, vc *discordgo.VoiceConnection, r io.Reader) (retErr error) {
// Recover from panic if vc.OpusSend is closed mid-send (e.g. on disconnect)
defer func() {
if rec := recover(); rec != nil {
retErr = fmt.Errorf("voice connection closed during playback")
}
}()
// Wait for the speaking transition to register
vc.Speaking(true)
defer vc.Speaking(false)
return audio.DecodeOggOpus(r, func(frame []byte) error {
select {
case <-ctx.Done():
return ctx.Err()
case vc.OpusSend <- frame:
return nil
}
})
}
func (c *DiscordChannel) receiveVoice(vc *discordgo.VoiceConnection, guildID string, chatID string) {
logger.InfoCF("discord", "Started listening for voice", map[string]any{"guild": guildID})
vc.AddHandler(func(_ *discordgo.VoiceConnection, vs *discordgo.VoiceSpeakingUpdate) {
if vs == nil {
return
}
c.setVoiceUserID(guildID, uint32(vs.SSRC), vs.UserID)
})
defer func() {
c.voiceMu.Lock()
delete(c.voiceSSRC, guildID)
c.voiceMu.Unlock()
}()
go func(ctx context.Context, vc *discordgo.VoiceConnection) {
// Recover from potential panics if OpusSend is closed mid-send.
defer func() {
if rec := recover(); rec != nil {
logger.WarnCF("discord", "Recovered from panic while sending wake-up frames", map[string]any{
"error": rec,
"guild": guildID,
})
}
}()
// If the voice connection or OpusSend are not available, nothing to do.
if vc == nil || vc.OpusSend == nil {
return
}
time.Sleep(250 * time.Millisecond) // Wait a bit for connection to settle
// Abort if the context has already been canceled.
select {
case <-ctx.Done():
return
default:
}
vc.Speaking(true)
defer vc.Speaking(false)
silenceFrame := []byte{0xF8, 0xFF, 0xFE}
for i := 0; i < 5; i++ {
select {
case <-ctx.Done():
return
case vc.OpusSend <- silenceFrame:
}
time.Sleep(20 * time.Millisecond)
}
logger.DebugCF("discord", "Sent wake-up silence frames", map[string]any{"guild": guildID})
}(c.ctx, vc)
sessionID := fmt.Sprintf("discord_vc_%s", guildID)
c.bus.PublishVoiceControl(c.ctx, bus.VoiceControl{
SessionID: sessionID,
Type: "state",
Action: "listening",
})
var sequence uint64 = 0
var interruptCount int
var lastInterruptAt time.Time
for {
select {
case <-c.ctx.Done():
return
case p, ok := <-vc.OpusRecv:
if !ok {
logger.InfoCF("discord", "Voice channel closed", map[string]any{"guild": guildID})
// Cancel any TTS that may still be playing
c.ttsMu.Lock()
if c.cancelTTS != nil {
c.cancelTTS()
c.cancelTTS = nil
}
c.ttsMu.Unlock()
return
}
if p == nil {
logger.DebugCF("discord", "Received nil Opus packet", nil)
continue
}
if len(p.Opus) == 0 {
logger.DebugCF("discord", "Received empty Opus packet", map[string]any{
"seq": p.Sequence,
"ssrc": p.SSRC,
})
continue
}
logger.DebugCF("discord", "Received Opus packet", map[string]any{
"seq": p.Sequence,
"len": len(p.Opus),
"ssrc": p.SSRC,
})
// Interruption detection: if user sends voice while TTS is playing,
// cancel TTS after a short debounce (3 packets in 200ms)
now := time.Now()
if now.Sub(lastInterruptAt) > 500*time.Millisecond {
interruptCount = 0
}
interruptCount++
lastInterruptAt = now
if interruptCount >= 3 {
c.ttsMu.Lock()
if c.cancelTTS != nil {
c.cancelTTS()
c.cancelTTS = nil
logger.InfoCF("discord", "TTS interrupted by user voice", nil)
}
c.ttsMu.Unlock()
interruptCount = 0
}
userID := c.voiceUserID(guildID, p.SSRC)
if userID == "" {
logger.DebugCF("discord", "Dropping voice packet without user mapping", map[string]any{
"ssrc": p.SSRC,
"guild": guildID,
})
continue
}
sender := bus.SenderInfo{
Platform: "discord",
PlatformID: userID,
CanonicalID: identity.BuildCanonicalID("discord", userID),
}
if !c.IsAllowedSender(sender) {
logger.DebugCF("discord", "Voice packet rejected by allowlist", map[string]any{
"user_id": userID,
"guild": guildID,
})
continue
}
sequence++
chunk := bus.AudioChunk{
SessionID: sessionID,
SpeakerID: userID,
ChatID: chatID,
Channel: "discord",
Sequence: sequence,
Timestamp: p.Timestamp,
SampleRate: 48000,
Channels: 2,
Format: "opus",
Data: p.Opus,
}
ctx, cancel := context.WithTimeout(c.ctx, 100*time.Millisecond)
err := c.bus.PublishAudioChunk(ctx, chunk)
cancel()
if err != nil {
logger.ErrorCF("discord", "Failed to publish audio chunk", map[string]any{
"guild": guildID,
"sessionID": sessionID,
"sequence": sequence,
"error": err.Error(),
})
}
}
}
}

View file

@ -6,6 +6,8 @@ import (
"strings" "strings"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
"github.com/sipeed/picoclaw/pkg/channels"
) )
// mentionPlaceholderRegex matches @_user_N placeholders inserted by Feishu for mentions. // mentionPlaceholderRegex matches @_user_N placeholders inserted by Feishu for mentions.
@ -145,3 +147,8 @@ func extractImageKeysRecursive(v any, feishuKeys, externalURLs *[]string) {
} }
} }
} }
// VoiceCapabilities returns the voice capabilities of the channel.
func (c *FeishuChannel) VoiceCapabilities() channels.VoiceCapabilities {
return channels.VoiceCapabilities{ASR: true, TTS: true}
}

View file

@ -684,3 +684,8 @@ func (c *LINEChannel) downloadContent(messageID, filename string) string {
}, },
}) })
} }
// VoiceCapabilities returns the voice capabilities of the channel.
func (c *LINEChannel) VoiceCapabilities() channels.VoiceCapabilities {
return channels.VoiceCapabilities{ASR: true, TTS: true}
}

View file

@ -1300,3 +1300,8 @@ func stripUserMentionWithRegexp(text string, userID id.UserID, mentionR *regexp.
cleaned = strings.TrimLeft(cleaned, ",:; ") cleaned = strings.TrimLeft(cleaned, ",:; ")
return strings.TrimSpace(cleaned) return strings.TrimSpace(cleaned)
} }
// VoiceCapabilities returns the voice capabilities of the channel.
func (c *MatrixChannel) VoiceCapabilities() channels.VoiceCapabilities {
return channels.VoiceCapabilities{ASR: true, TTS: true}
}

View file

@ -1104,3 +1104,8 @@ func truncate(s string, n int) string {
} }
return string(runes[:n]) + "..." return string(runes[:n]) + "..."
} }
// VoiceCapabilities returns the voice capabilities of the channel.
func (c *OneBotChannel) VoiceCapabilities() channels.VoiceCapabilities {
return channels.VoiceCapabilities{ASR: true, TTS: true}
}

View file

@ -1002,3 +1002,8 @@ func sanitizeURLs(text string) string {
return scheme + domain + path return scheme + domain + path
}) })
} }
// VoiceCapabilities returns the voice capabilities of the channel.
func (c *QQChannel) VoiceCapabilities() channels.VoiceCapabilities {
return channels.VoiceCapabilities{ASR: true, TTS: true}
}

View file

@ -1133,3 +1133,8 @@ func cryptoRandInt() int {
_, _ = rand.Read(b[:]) _, _ = rand.Read(b[:])
return int(binary.BigEndian.Uint32(b[:])) | 1 // ensure non-zero return int(binary.BigEndian.Uint32(b[:])) | 1 // ensure non-zero
} }
// VoiceCapabilities returns the voice capabilities of the channel.
func (c *TelegramChannel) VoiceCapabilities() channels.VoiceCapabilities {
return channels.VoiceCapabilities{ASR: true, TTS: true}
}

View file

@ -0,0 +1,58 @@
package channels
// VoiceCapabilities describes whether ASR (speech-to-text) and TTS (text-to-speech)
// are available for a channel under the current configuration.
type VoiceCapabilities struct {
ASR bool
TTS bool
}
// VoiceCapabilityProvider is an optional interface for channels that want to
// explicitly declare their ASR/TTS support.
type VoiceCapabilityProvider interface {
VoiceCapabilities() VoiceCapabilities
}
// Deprecated: Channels should implement VoiceCapabilityProvider instead.
// To be removed once all existing capable channels conform to the interface.
var asrCapableChannels = map[string]bool{
"discord": true,
"telegram": true,
"matrix": true,
"qq": true,
"weixin": true,
"line": true,
"feishu": true,
"onebot": true,
}
// DetectVoiceCapabilities returns ASR/TTS availability for a channel, gated by
// whether providers are configured.
func DetectVoiceCapabilities(channelName string, ch Channel, asrAvailable bool, ttsAvailable bool) VoiceCapabilities {
if ch == nil {
return VoiceCapabilities{}
}
if vcp, ok := ch.(VoiceCapabilityProvider); ok {
caps := vcp.VoiceCapabilities()
if !asrAvailable {
caps.ASR = false
}
if !ttsAvailable {
caps.TTS = false
}
return caps
}
caps := VoiceCapabilities{}
if asrAvailable {
caps.ASR = asrCapableChannels[channelName]
}
if ttsAvailable {
if _, ok := ch.(MediaSender); ok {
caps.TTS = true
}
}
return caps
}

View file

@ -402,3 +402,8 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st
return nil, nil return nil, nil
} }
// VoiceCapabilities returns the voice capabilities of the channel.
func (c *WeixinChannel) VoiceCapabilities() channels.VoiceCapabilities {
return channels.VoiceCapabilities{ASR: true, TTS: true}
}

View file

@ -559,8 +559,8 @@ type DevicesConfig struct {
type VoiceConfig struct { type VoiceConfig struct {
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"` ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"`
TTSModelName string `json:"tts_model_name,omitempty" env:"PICOCLAW_VOICE_TTS_MODEL_NAME"`
EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"` EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
ElevenLabsAPIKey string `json:"elevenlabs_api_key,omitempty" env:"PICOCLAW_VOICE_ELEVENLABS_API_KEY"`
} }
// ModelConfig represents a model-centric provider configuration. // ModelConfig represents a model-centric provider configuration.
@ -829,6 +829,7 @@ type ToolsConfig struct {
Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
SendTTS ToolConfig `json:"send_tts" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_TTS_"`
Spawn ToolConfig `json:"spawn" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` Spawn ToolConfig `json:"spawn" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
SpawnStatus ToolConfig `json:"spawn_status" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` SpawnStatus ToolConfig `json:"spawn_status" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"`
SPI ToolConfig `json:"spi" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPI_"` SPI ToolConfig `json:"spi" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPI_"`
@ -1281,6 +1282,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
return t.WebFetch.Enabled return t.WebFetch.Enabled
case "send_file": case "send_file":
return t.SendFile.Enabled return t.SendFile.Enabled
case "send_tts":
return t.SendTTS.Enabled
case "write_file": case "write_file":
return t.WriteFile.Enabled return t.WriteFile.Enabled
case "mcp": case "mcp":

View file

@ -434,6 +434,9 @@ func DefaultConfig() *Config {
SendFile: ToolConfig{ SendFile: ToolConfig{
Enabled: true, Enabled: true,
}, },
SendTTS: ToolConfig{
Enabled: false,
},
MCP: MCPConfig{ MCP: MCPConfig{
ToolConfig: ToolConfig{ ToolConfig: ToolConfig{
Enabled: false, Enabled: false,

View file

@ -6,6 +6,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@ -13,6 +14,8 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/audio/asr"
"github.com/sipeed/picoclaw/pkg/audio/tts"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
@ -41,7 +44,6 @@ import (
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/voice"
) )
const ( const (
@ -61,6 +63,7 @@ type services struct {
ChannelManager *channels.Manager ChannelManager *channels.Manager
DeviceService *devices.Service DeviceService *devices.Service
HealthServer *health.Server HealthServer *health.Server
VoiceAgentCancel context.CancelFunc
manualReloadChan chan struct{} manualReloadChan chan struct{}
reloading atomic.Bool reloading atomic.Bool
authToken string authToken string
@ -70,6 +73,27 @@ type startupBlockedProvider struct {
reason string reason string
} }
func logChannelVoiceCapabilities(cm *channels.Manager, asrAvailable bool, ttsAvailable bool) {
if cm == nil {
return
}
names := cm.GetEnabledChannels()
sort.Strings(names)
for _, name := range names {
ch, ok := cm.GetChannel(name)
if !ok {
continue
}
caps := channels.DetectVoiceCapabilities(name, ch, asrAvailable, ttsAvailable)
logger.InfoCF("voice", "Channel voice capabilities", map[string]any{
"channel": name,
"asr": caps.ASR,
"tts": caps.TTS,
})
}
}
func (p *startupBlockedProvider) Chat( func (p *startupBlockedProvider) Chat(
_ context.Context, _ context.Context,
_ []providers.Message, _ []providers.Message,
@ -125,6 +149,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
// Enforce singleton: write PID file with generated token. // Enforce singleton: write PID file with generated token.
pidData, err := pid.WritePidFile(homePath, cfg.Gateway.Host, cfg.Gateway.Port) pidData, err := pid.WritePidFile(homePath, cfg.Gateway.Host, cfg.Gateway.Port)
if err != nil { if err != nil {
logger.Warnf("write pid file failed: %v", err)
return fmt.Errorf("singleton check failed: %w", err) return fmt.Errorf("singleton check failed: %w", err)
} }
defer pid.RemovePidFile(homePath) defer pid.RemovePidFile(homePath)
@ -337,11 +362,14 @@ func setupAndStartServices(
agentLoop.SetChannelManager(runningServices.ChannelManager) agentLoop.SetChannelManager(runningServices.ChannelManager)
agentLoop.SetMediaStore(runningServices.MediaStore) agentLoop.SetMediaStore(runningServices.MediaStore)
if transcriber := voice.DetectTranscriber(cfg); transcriber != nil { transcriber := asr.DetectTranscriber(cfg)
if transcriber != nil {
agentLoop.SetTranscriber(transcriber) agentLoop.SetTranscriber(transcriber)
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
} }
ttsAvailable := tts.DetectTTS(cfg) != nil
enabledChannels := runningServices.ChannelManager.GetEnabledChannels() enabledChannels := runningServices.ChannelManager.GetEnabledChannels()
if len(enabledChannels) > 0 { if len(enabledChannels) > 0 {
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
@ -358,6 +386,16 @@ func setupAndStartServices(
return nil, fmt.Errorf("error starting channels: %w", err) return nil, fmt.Errorf("error starting channels: %w", err)
} }
logChannelVoiceCapabilities(runningServices.ChannelManager, transcriber != nil, ttsAvailable)
if transcriber != nil {
// Start Voice Agent Orchestrator after channels are ready.
vaCtx, vaCancel := context.WithCancel(context.Background())
runningServices.VoiceAgentCancel = vaCancel
voiceAgent := asr.NewAgent(msgBus, transcriber)
voiceAgent.Start(vaCtx)
}
fmt.Printf( fmt.Printf(
"✓ Health endpoints available at http://%s:%d/health, /ready and /reload (POST)\n", "✓ Health endpoints available at http://%s:%d/health, /ready and /reload (POST)\n",
cfg.Gateway.Host, cfg.Gateway.Host,
@ -387,6 +425,9 @@ func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Dura
if !isReload && runningServices.ChannelManager != nil { if !isReload && runningServices.ChannelManager != nil {
runningServices.ChannelManager.StopAll(shutdownCtx) runningServices.ChannelManager.StopAll(shutdownCtx)
} }
if runningServices.VoiceAgentCancel != nil {
runningServices.VoiceAgentCancel()
}
if runningServices.DeviceService != nil { if runningServices.DeviceService != nil {
runningServices.DeviceService.Stop() runningServices.DeviceService.Stop()
} }
@ -563,14 +604,22 @@ func restartServices(
fmt.Println(" ✓ Device event service restarted") fmt.Println(" ✓ Device event service restarted")
} }
transcriber := voice.DetectTranscriber(cfg) transcriber := asr.DetectTranscriber(cfg)
al.SetTranscriber(transcriber) al.SetTranscriber(transcriber)
if transcriber != nil { if transcriber != nil {
logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
// Start Voice Agent Orchestrator on reload
vaCtx, vaCancel := context.WithCancel(context.Background())
runningServices.VoiceAgentCancel = vaCancel
voiceAgent := asr.NewAgent(msgBus, transcriber)
voiceAgent.Start(vaCtx)
} else { } else {
logger.InfoCF("voice", "Transcription disabled", nil) logger.InfoCF("voice", "Transcription disabled", nil)
} }
ttsAvailable := tts.DetectTTS(cfg) != nil
logChannelVoiceCapabilities(runningServices.ChannelManager, transcriber != nil, ttsAvailable)
// NOTE: PID file is written once at startup and not updated on reload. // NOTE: PID file is written once at startup and not updated on reload.
// Changing the gateway listen address requires a full restart. // Changing the gateway listen address requires a full restart.

View file

@ -94,6 +94,7 @@ func WritePidFile(homePath, host string, port int) (*PidFileData, error) {
os.Remove(tmp) os.Remove(tmp)
return nil, fmt.Errorf("failed to rename pid file: %w", err) return nil, fmt.Errorf("failed to rename pid file: %w", err)
} }
logger.Debugf("wrote pid file: %s success", pidPath)
return data, nil return data, nil
} }
@ -108,10 +109,12 @@ func ReadPidFileWithCheck(homePath string) *PidFileData {
pidPath := pidFilePath(homePath) pidPath := pidFilePath(homePath)
data, err := readPidFileUnlocked(pidPath) data, err := readPidFileUnlocked(pidPath)
if err != nil { if err != nil {
logger.Debugf("failed to read pid file: %s", err)
return nil return nil
} }
if !isProcessRunning(data.PID) { if !isProcessRunning(data.PID) {
logger.Debugf("process not running, remove pid file: %s", pidPath)
os.Remove(pidPath) os.Remove(pidPath)
return nil return nil
} }

View file

@ -98,6 +98,19 @@ func ExtractProtocol(model string) (protocol, modelID string) {
return protocol, modelID return protocol, modelID
} }
// ResolveAPIBase returns the configured API base, or the protocol default when
// the model uses an HTTP-based provider family with a known default endpoint.
func ResolveAPIBase(cfg *config.ModelConfig) string {
if cfg == nil {
return ""
}
if apiBase := strings.TrimSpace(cfg.APIBase); apiBase != "" {
return strings.TrimRight(apiBase, "/")
}
protocol, _ := ExtractProtocol(cfg.Model)
return strings.TrimRight(getDefaultAPIBase(protocol), "/")
}
// CreateProviderFromConfig creates a provider based on the ModelConfig. // CreateProviderFromConfig creates a provider based on the ModelConfig.
// It uses the protocol prefix in the Model field to determine which provider to create. // It uses the protocol prefix in the Model field to determine which provider to create.
// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq, gemini), // Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq, gemini),

82
pkg/tools/tts_send.go Normal file
View file

@ -0,0 +1,82 @@
package tools
import (
"context"
"strings"
"github.com/sipeed/picoclaw/pkg/audio/tts"
"github.com/sipeed/picoclaw/pkg/media"
)
type SendTTSTool struct {
provider tts.TTSProvider
mediaStore media.MediaStore
}
func NewSendTTSTool(provider tts.TTSProvider, store media.MediaStore) *SendTTSTool {
return &SendTTSTool{
provider: provider,
mediaStore: store,
}
}
func (t *SendTTSTool) Name() string { return "send_tts" }
func (t *SendTTSTool) Description() string {
return "Synthesize speech from text and send it as an audio file to the user."
}
func (t *SendTTSTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{
"type": "string",
"description": "The text to synthesize into speech. NOTE: Reply in a highly concise, conversational, oral style suitable for text-to-speech. Do not use markdown, emojis, asterisks, or code blocks. Speak naturally.",
},
"filename": map[string]any{
"type": "string",
"description": "Optional filename for the audio file (e.g., response.ogg).",
},
},
"required": []string{"text"},
}
}
func (t *SendTTSTool) SetMediaStore(store media.MediaStore) {
t.mediaStore = store
}
func (t *SendTTSTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
text, _ := args["text"].(string)
text = strings.TrimSpace(text)
if text == "" {
return ErrorResult("text is required")
}
channel := ToolChannel(ctx)
chatID := ToolChatID(ctx)
filename, _ := args["filename"].(string)
ref, err := tts.SynthesizeAndStore(
ctx,
t.provider,
t.mediaStore,
text,
filename,
channel,
chatID,
)
if err != nil {
return ErrorResult(err.Error()).WithError(err)
}
// Return with ForUser set to original text, Media containing the audio ref,
// and mark as ResponseHandled so the audio is sent immediately without LLM intervention.
return &ToolResult{
ForLLM: "TTS audio sent",
ForUser: text,
Media: []string{ref},
ResponseHandled: true,
}
}

View file

@ -1,151 +0,0 @@
package voice
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
type GroqTranscriber struct {
apiKey string
apiBase string
httpClient *http.Client
}
func NewGroqTranscriber(apiKey string) *GroqTranscriber {
logger.DebugCF("voice", "Creating Groq transcriber", map[string]any{"has_api_key": apiKey != ""})
apiBase := "https://api.groq.com/openai/v1"
return &GroqTranscriber{
apiKey: apiKey,
apiBase: apiBase,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
}
}
func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
logger.InfoCF("voice", "Starting transcription", map[string]any{"audio_file": audioFilePath})
audioFile, err := os.Open(audioFilePath)
if err != nil {
logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err})
return nil, fmt.Errorf("failed to open audio file: %w", err)
}
defer audioFile.Close()
fileInfo, err := audioFile.Stat()
if err != nil {
logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err})
return nil, fmt.Errorf("failed to get file info: %w", err)
}
logger.DebugCF("voice", "Audio file details", map[string]any{
"size_bytes": fileInfo.Size(),
"file_name": filepath.Base(audioFilePath),
})
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath))
if err != nil {
logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err})
return nil, fmt.Errorf("failed to create form file: %w", err)
}
copied, err := io.Copy(part, audioFile)
if err != nil {
logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err})
return nil, fmt.Errorf("failed to copy file content: %w", err)
}
logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied})
if err = writer.WriteField("model", "whisper-large-v3"); err != nil {
logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err})
return nil, fmt.Errorf("failed to write model field: %w", err)
}
if err = writer.WriteField("response_format", "json"); err != nil {
logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err})
return nil, fmt.Errorf("failed to write response_format field: %w", err)
}
if err = writer.Close(); err != nil {
logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err})
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
}
url := t.apiBase + "/audio/transcriptions"
req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody)
if err != nil {
logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err})
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+t.apiKey)
logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]any{
"url": url,
"request_size_bytes": requestBody.Len(),
"file_size_bytes": fileInfo.Size(),
})
resp, err := t.httpClient.Do(req)
if err != nil {
logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err})
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err})
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
logger.ErrorCF("voice", "API error", map[string]any{
"status_code": resp.StatusCode,
"response": string(body),
})
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
logger.DebugCF("voice", "Received response from Groq API", map[string]any{
"status_code": resp.StatusCode,
"response_size_bytes": len(body),
})
var result TranscriptionResponse
if err := json.Unmarshal(body, &result); err != nil {
logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err})
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
logger.InfoCF("voice", "Transcription completed successfully", map[string]any{
"text_length": len(result.Text),
"language": result.Language,
"duration_seconds": result.Duration,
"transcription_preview": utils.Truncate(result.Text, 50),
})
return &result, nil
}
func (t *GroqTranscriber) Name() string {
return "groq"
}

View file

@ -1,84 +0,0 @@
package voice
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
var _ Transcriber = (*GroqTranscriber)(nil)
func TestGroqTranscriberName(t *testing.T) {
tr := NewGroqTranscriber("sk-test")
if got := tr.Name(); got != "groq" {
t.Errorf("Name() = %q, want %q", got, "groq")
}
}
func TestGroqTranscribe(t *testing.T) {
// Write a minimal fake audio file so the transcriber can open and send it.
tmpDir := t.TempDir()
audioPath := filepath.Join(tmpDir, "clip.ogg")
if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil {
t.Fatalf("failed to write fake audio file: %v", err)
}
t.Run("success", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/audio/transcriptions" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer sk-test" {
t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization"))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(TranscriptionResponse{
Text: "hello world",
Language: "en",
Duration: 1.5,
})
}))
defer srv.Close()
tr := NewGroqTranscriber("sk-test")
tr.apiBase = srv.URL
resp, err := tr.Transcribe(context.Background(), audioPath)
if err != nil {
t.Fatalf("Transcribe() error: %v", err)
}
if resp.Text != "hello world" {
t.Errorf("Text = %q, want %q", resp.Text, "hello world")
}
if resp.Language != "en" {
t.Errorf("Language = %q, want %q", resp.Language, "en")
}
})
t.Run("api error", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized)
}))
defer srv.Close()
tr := NewGroqTranscriber("sk-bad")
tr.apiBase = srv.URL
_, err := tr.Transcribe(context.Background(), audioPath)
if err == nil {
t.Fatal("expected error for non-200 response, got nil")
}
})
t.Run("missing file", func(t *testing.T) {
tr := NewGroqTranscriber("sk-test")
_, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
if err == nil {
t.Fatal("expected error for missing file, got nil")
}
})
}

View file

@ -1,68 +0,0 @@
package voice
import (
"context"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
type Transcriber interface {
Name() string
Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
}
type TranscriptionResponse struct {
Text string `json:"text"`
Language string `json:"language,omitempty"`
Duration float64 `json:"duration,omitempty"`
}
func supportsAudioTranscription(model string) bool {
protocol, _ := providers.ExtractProtocol(model)
switch protocol {
case "openai", "azure", "azure-openai",
"litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding":
// These protocols all go through the OpenAI-compatible or Azure provider path in
// providers.CreateProviderFromConfig, so they are the only ones that can supply
// the audio media payload shape expected by NewAudioModelTranscriber.
// TODO: Further restrict this by modelID, since not every model under these
// protocols supports audio transcription.
return true
default:
return false
}
}
// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or
// nil if no supported transcription provider is configured.
func DetectTranscriber(cfg *config.Config) Transcriber {
if modelName := strings.TrimSpace(cfg.Voice.ModelName); modelName != "" {
modelCfg, err := cfg.GetModelConfig(modelName)
if err != nil {
return nil
}
if supportsAudioTranscription(modelCfg.Model) {
return NewAudioModelTranscriber(modelCfg)
}
}
// ElevenLabs voice config (supports Scribe STT).
if key := strings.TrimSpace(cfg.Voice.ElevenLabsAPIKey); key != "" {
return NewElevenLabsTranscriber(key)
}
// Fall back to any model-list entry that uses the groq/ protocol.
for _, mc := range cfg.ModelList {
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey() != "" {
return NewGroqTranscriber(mc.APIKey())
}
}
return nil
}

View file

@ -628,6 +628,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
gateway.mu.Lock() gateway.mu.Lock()
if gateway.cmd == cmd { if gateway.cmd == cmd {
gateway.pidData = pd gateway.pidData = pd
gateway.picoToken = cfg.Channels.Pico.Token.String()
setGatewayRuntimeStatusLocked("running") setGatewayRuntimeStatusLocked("running")
} }
gateway.mu.Unlock() gateway.mu.Unlock()
@ -922,34 +923,13 @@ func (h *Handler) gatewayStatusData() map[string]any {
data["pid"] = pidData.PID data["pid"] = pidData.PID
gateway.mu.Unlock() gateway.mu.Unlock()
} else { } else {
// Fallback: probe health endpoint to get pid and status // Intentionally skip health probe here; the startup goroutine
_, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second) // (startGatewayLocked) already handles liveness detection via
if err != nil { // pidFile polling and health fallback.
gateway.mu.Lock() gateway.mu.Lock()
data["gateway_status"] = gatewayStatusWithoutHealthLocked() data["gateway_status"] = gatewayStatusWithoutHealthLocked()
gateway.pidData = nil gateway.pidData = nil
gateway.mu.Unlock() gateway.mu.Unlock()
logger.ErrorC("gateway", fmt.Sprintf("Gateway health check failed: %v", err))
} else {
logger.InfoC("gateway", fmt.Sprintf("Gateway health status: %d", statusCode))
if statusCode != http.StatusOK {
gateway.mu.Lock()
setGatewayRuntimeStatusLocked("error")
gateway.pidData = nil
gateway.mu.Unlock()
data["gateway_status"] = "error"
data["status_code"] = statusCode
} else {
gateway.mu.Lock()
setGatewayRuntimeStatusLocked("running")
bootDefaultModel := gateway.bootDefaultModel
if bootDefaultModel != "" {
data["boot_default_model"] = bootDefaultModel
}
data["gateway_status"] = "running"
gateway.mu.Unlock()
}
}
} }
gatewayStatus, _ := data["gateway_status"].(string) gatewayStatus, _ := data["gateway_status"].(string)

View file

@ -15,8 +15,11 @@ import (
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
ppid "github.com/sipeed/picoclaw/pkg/pid"
"github.com/sipeed/picoclaw/web/backend/utils" "github.com/sipeed/picoclaw/web/backend/utils"
) )
@ -444,7 +447,7 @@ func TestGatewayStatusKeepsRunningWhenHealthProbeFailsAfterRunning(t *testing.T)
} }
} }
func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) { func TestGatewayStatusReportsRunningFromPidProbe(t *testing.T) {
resetGatewayTestState(t) resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json") configPath := filepath.Join(t.TempDir(), "config.json")
@ -468,6 +471,9 @@ func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) {
return mockGatewayHealthResponse(http.StatusOK, cmd.Process.Pid), nil return mockGatewayHealthResponse(http.StatusOK, cmd.Process.Pid), nil
} }
_, err := ppid.WritePidFile(globalConfigDir(), "localhost", 0)
require.NoError(t, err)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req) mux.ServeHTTP(rec, req)
@ -513,6 +519,8 @@ func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("FindProcess() error = %v", err) t.Fatalf("FindProcess() error = %v", err)
} }
_, err = ppid.WritePidFile(globalConfigDir(), "localhost", 0)
require.NoError(t, err)
bootSignature := computeConfigSignature(cfg) bootSignature := computeConfigSignature(cfg)
gateway.mu.Lock() gateway.mu.Lock()

View file

@ -1,19 +1,36 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"hash/fnv"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"sync"
"time" "time"
"golang.org/x/sync/singleflight"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
const modelProbeTimeout = 800 * time.Millisecond const (
modelProbeTimeout = 800 * time.Millisecond
modelProbeSuccessBaseInterval = 2 * time.Second
modelProbeSuccessMaxInterval = 60 * time.Second
modelProbeFailureBaseInterval = 1 * time.Second
modelProbeFailureMaxInterval = 30 * time.Second
modelProbeBackoffMaxShift = 8
modelProbeCacheMaxEntries = 1024
modelProbeCacheEntryTTL = 30 * time.Minute
modelProbeCacheTrimToEntries = modelProbeCacheMaxEntries * 8 / 10
modelProbeTTLGCInterval = 1 * time.Minute
)
const ( const (
modelStatusAvailable = "available" modelStatusAvailable = "available"
@ -30,8 +47,41 @@ var (
probeTCPServiceFunc = probeTCPService probeTCPServiceFunc = probeTCPService
probeOllamaModelFunc = probeOllamaModel probeOllamaModelFunc = probeOllamaModel
probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel
modelProbeNowFunc = time.Now
modelProbeState = newModelProbeCacheState()
) )
type modelProbeCacheState struct {
mu sync.RWMutex
cache map[string]*modelProbeCacheEntry
group singleflight.Group
nextTTLGCAt time.Time
}
type modelProbeCacheEntry struct {
lastResult bool
hasResult bool
successStreak int
failureStreak int
nextProbeAt time.Time
updatedAt time.Time
}
func newModelProbeCacheState() *modelProbeCacheState {
return &modelProbeCacheState{cache: map[string]*modelProbeCacheEntry{}}
}
func resetModelProbeCache() {
modelProbeState.resetForTest()
}
func (s *modelProbeCacheState) resetForTest() {
s.mu.Lock()
defer s.mu.Unlock()
s.cache = map[string]*modelProbeCacheEntry{}
s.nextTTLGCAt = time.Time{}
}
func hasModelConfiguration(m *config.ModelConfig) bool { func hasModelConfiguration(m *config.ModelConfig) bool {
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod)) authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
apiKey := strings.TrimSpace(m.APIKey()) apiKey := strings.TrimSpace(m.APIKey())
@ -93,6 +143,34 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool {
} }
func probeLocalModelAvailability(m *config.ModelConfig) bool { func probeLocalModelAvailability(m *config.ModelConfig) bool {
cacheKey := modelProbeCacheKey(m)
return modelProbeState.probe(cacheKey, func() bool {
return runLocalModelProbe(m)
})
}
func (s *modelProbeCacheState) probe(cacheKey string, probeFunc func() bool) bool {
now := modelProbeNowFunc()
if cachedResult, ok := s.getCachedResult(cacheKey, now); ok {
return cachedResult
}
v, _, _ := s.group.Do(cacheKey, func() (any, error) {
now = modelProbeNowFunc()
if cachedResult, ok := s.getCachedResult(cacheKey, now); ok {
return cachedResult, nil
}
result := probeFunc()
s.setCachedResult(cacheKey, result, now)
return result, nil
})
result, _ := v.(bool)
return result
}
func runLocalModelProbe(m *config.ModelConfig) bool {
apiBase := modelProbeAPIBase(m) apiBase := modelProbeAPIBase(m)
protocol, modelID := splitModel(m.Model) protocol, modelID := splitModel(m.Model)
switch protocol { switch protocol {
@ -112,6 +190,195 @@ func probeLocalModelAvailability(m *config.ModelConfig) bool {
} }
} }
func modelProbeCacheKey(m *config.ModelConfig) string {
protocol, modelID := splitModel(m.Model)
apiBaseRaw := modelProbeAPIBase(m)
apiBase := strings.ToLower(strings.TrimRight(strings.TrimSpace(apiBaseRaw), "/"))
apiKeyFingerprint := modelProbeAPIKeyFingerprint(m.APIKey())
var b strings.Builder
b.Grow(len(protocol) + len(modelID) + len(apiBase) + len(apiKeyFingerprint) + 8)
b.WriteString(protocol)
b.WriteByte('|')
b.WriteString(modelID)
b.WriteByte('|')
b.WriteString(apiBase)
b.WriteByte('|')
b.WriteString(apiKeyFingerprint)
return b.String()
}
func modelProbeAPIKeyFingerprint(raw string) string {
apiKey := strings.TrimSpace(raw)
if apiKey == "" {
return "none"
}
h := fnv.New64a()
_, _ = h.Write([]byte(apiKey))
return strconv.FormatUint(h.Sum64(), 36)
}
func (s *modelProbeCacheState) getCachedResult(cacheKey string, now time.Time) (bool, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
entry, ok := s.cache[cacheKey]
if !ok || !entry.hasResult {
return false, false
}
if now.Before(entry.nextProbeAt) {
return entry.lastResult, true
}
return false, false
}
func (s *modelProbeCacheState) setCachedResult(cacheKey string, result bool, now time.Time) {
s.mu.Lock()
entry, ok := s.cache[cacheKey]
if !ok {
entry = &modelProbeCacheEntry{}
s.cache[cacheKey] = entry
}
entry.lastResult = result
entry.hasResult = true
entry.updatedAt = now
var delay time.Duration
if result {
entry.successStreak++
entry.failureStreak = 0
delay = modelProbeBackoffDelay(
modelProbeSuccessBaseInterval,
modelProbeSuccessMaxInterval,
entry.successStreak,
)
} else {
entry.failureStreak++
entry.successStreak = 0
delay = modelProbeBackoffDelay(
modelProbeFailureBaseInterval,
modelProbeFailureMaxInterval,
entry.failureStreak,
)
}
entry.nextProbeAt = now.Add(delay)
shouldRunTTLGC := modelProbeCacheEntryTTL > 0 && (s.nextTTLGCAt.IsZero() || !now.Before(s.nextTTLGCAt))
if shouldRunTTLGC {
s.nextTTLGCAt = now.Add(modelProbeTTLGCInterval)
}
shouldRunSizeGC := len(s.cache) > modelProbeCacheMaxEntries
s.mu.Unlock()
if shouldRunTTLGC || shouldRunSizeGC {
s.gc(now, shouldRunTTLGC)
}
}
func (s *modelProbeCacheState) gc(now time.Time, runTTL bool) {
type evictionCandidate struct {
key string
updatedAt time.Time
}
var expireBefore time.Time
if runTTL && modelProbeCacheEntryTTL > 0 {
expireBefore = now.Add(-modelProbeCacheEntryTTL)
}
s.mu.RLock()
cacheLen := len(s.cache)
if cacheLen == 0 {
s.mu.RUnlock()
return
}
expiredKeys := make([]string, 0)
if !expireBefore.IsZero() {
expiredKeys = make([]string, 0, min(cacheLen/8+1, 64))
for key, entry := range s.cache {
if entry.updatedAt.Before(expireBefore) {
expiredKeys = append(expiredKeys, key)
}
}
}
effectiveLen := cacheLen - len(expiredKeys)
removeCount := max(effectiveLen-modelProbeCacheTrimToEntries, 0)
candidates := make([]evictionCandidate, 0)
if removeCount > 0 {
candidates = make([]evictionCandidate, 0, effectiveLen)
for key, entry := range s.cache {
if !expireBefore.IsZero() && entry.updatedAt.Before(expireBefore) {
continue
}
candidates = append(candidates, evictionCandidate{key: key, updatedAt: entry.updatedAt})
}
}
s.mu.RUnlock()
if len(expiredKeys) == 0 && len(candidates) == 0 {
return
}
toEvict := map[string]time.Time{}
for i := 0; i < removeCount && len(candidates) > 0; i++ {
oldest := 0
for j := 1; j < len(candidates); j++ {
if candidates[j].updatedAt.Before(candidates[oldest].updatedAt) {
oldest = j
}
}
victim := candidates[oldest]
toEvict[victim.key] = victim.updatedAt
candidates[oldest] = candidates[len(candidates)-1]
candidates = candidates[:len(candidates)-1]
}
s.mu.Lock()
defer s.mu.Unlock()
if !expireBefore.IsZero() {
for _, key := range expiredKeys {
entry, ok := s.cache[key]
if ok && entry.updatedAt.Before(expireBefore) {
delete(s.cache, key)
}
}
}
for key, victimUpdatedAt := range toEvict {
entry, ok := s.cache[key]
if ok && !entry.updatedAt.After(victimUpdatedAt) {
delete(s.cache, key)
}
}
}
func modelProbeBackoffDelay(base, maxDelay time.Duration, streak int) time.Duration {
if streak <= 0 {
streak = 1
}
shift := min(streak-1, modelProbeBackoffMaxShift)
delay := base * time.Duration(1<<shift)
if maxDelay > 0 && (delay > maxDelay || delay < 0) {
return maxDelay
}
if delay <= 0 {
return base
}
return delay
}
func modelProbeAPIBase(m *config.ModelConfig) string { func modelProbeAPIBase(m *config.ModelConfig) string {
if apiBase := strings.TrimSpace(m.APIBase); apiBase != "" { if apiBase := strings.TrimSpace(m.APIBase); apiBase != "" {
return normalizeModelProbeAPIBase(apiBase) return normalizeModelProbeAPIBase(apiBase)
@ -207,7 +474,11 @@ func probeTCPService(raw string) bool {
return false return false
} }
conn, err := net.DialTimeout("tcp", hostPort, modelProbeTimeout) ctx, cancel := context.WithTimeout(context.Background(), modelProbeTimeout)
defer cancel()
dialer := &net.Dialer{}
conn, err := dialer.DialContext(ctx, "tcp", hostPort)
if err != nil { if err != nil {
return false return false
} }
@ -262,7 +533,10 @@ func probeOpenAICompatibleModel(apiBase, modelID, apiKey string) bool {
} }
func getJSON(rawURL string, out any, apiKey string) error { func getJSON(rawURL string, out any, apiKey string) error {
req, err := http.NewRequest(http.MethodGet, rawURL, nil) ctx, cancel := context.WithTimeout(context.Background(), modelProbeTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil { if err != nil {
return err return err
} }
@ -270,7 +544,7 @@ func getJSON(rawURL string, out any, apiKey string) error {
req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Authorization", "Bearer "+apiKey)
} }
client := &http.Client{Timeout: modelProbeTimeout} client := &http.Client{}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return err return err
@ -336,10 +610,29 @@ func ollamaModelMatches(candidate, want string) bool {
if candidate == "" || want == "" { if candidate == "" || want == "" {
return false return false
} }
if strings.EqualFold(candidate, want) {
return true candidateBase, candidateTag := splitOllamaModel(candidate)
wantBase, wantTag := splitOllamaModel(want)
if candidateBase == "" || wantBase == "" {
return false
} }
base, _, _ := strings.Cut(candidate, ":") if candidateTag == "" {
return strings.EqualFold(base, want) candidateTag = "latest"
}
if wantTag == "" {
wantTag = "latest"
}
return strings.EqualFold(candidateBase, wantBase) && strings.EqualFold(candidateTag, wantTag)
}
func splitOllamaModel(raw string) (base, tag string) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", ""
}
base, tag, _ = strings.Cut(raw, ":")
return strings.TrimSpace(base), strings.TrimSpace(tag)
} }

View file

@ -3,7 +3,10 @@ package api
import ( import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync"
"sync/atomic"
"testing" "testing"
"time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
@ -85,3 +88,307 @@ func TestProbeLocalModelAvailability_LMStudioUsesOpenAICompatibleProbe(t *testin
t.Fatal("probeOpenAICompatibleModelFunc was not called for lmstudio") t.Fatal("probeOpenAICompatibleModelFunc was not called for lmstudio")
} }
} }
func TestModelProbeCacheKey_DifferentAPIKeysProduceDifferentKeys(t *testing.T) {
base := &config.ModelConfig{
ModelName: "local-vllm",
Model: "vllm/custom-model",
APIBase: "http://127.0.0.1:8000/v1",
AuthMethod: "local",
ConnectMode: "",
}
m1 := *base
m1.SetAPIKey("key-a")
m2 := *base
m2.SetAPIKey("key-b")
k1 := modelProbeCacheKey(&m1)
k2 := modelProbeCacheKey(&m2)
if k1 == k2 {
t.Fatal("modelProbeCacheKey() should differ when api key changes")
}
}
func TestModelProbeCacheKey_NormalizesTrailingSlashInAPIBase(t *testing.T) {
m1 := &config.ModelConfig{
ModelName: "local-vllm",
Model: "vllm/custom-model",
APIBase: "http://127.0.0.1:8000/v1",
}
m2 := &config.ModelConfig{
ModelName: "local-vllm",
Model: "vllm/custom-model",
APIBase: "http://127.0.0.1:8000/v1/",
}
k1 := modelProbeCacheKey(m1)
k2 := modelProbeCacheKey(m2)
if k1 != k2 {
t.Fatalf("modelProbeCacheKey() mismatch for equivalent api_base values: %q vs %q", k1, k2)
}
}
func TestModelProbeCacheKey_IgnoresDisplayAndConnectionFields(t *testing.T) {
base := &config.ModelConfig{
ModelName: "vllm-one",
Model: "vllm/custom-model",
APIBase: "http://127.0.0.1:8000/v1",
AuthMethod: "none",
ConnectMode: "http",
}
changed := &config.ModelConfig{
ModelName: "vllm-two",
Model: "vllm/custom-model",
APIBase: "http://127.0.0.1:8000/v1",
AuthMethod: "token",
ConnectMode: "ws",
}
k1 := modelProbeCacheKey(base)
k2 := modelProbeCacheKey(changed)
if k1 != k2 {
t.Fatalf("modelProbeCacheKey() should ignore non-probe fields, got %q vs %q", k1, k2)
}
}
func TestProbeLocalModelAvailability_SuccessBackoff(t *testing.T) {
resetModelProbeHooks(t)
now := time.Unix(1700000000, 0)
modelProbeNowFunc = func() time.Time { return now }
calls := 0
probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
calls++
return true
}
model := &config.ModelConfig{
ModelName: "local-vllm",
Model: "vllm/custom-model",
APIBase: "http://127.0.0.1:8000/v1",
}
if !probeLocalModelAvailability(model) {
t.Fatal("first probe result = false, want true")
}
if calls != 1 {
t.Fatalf("probe calls after first probe = %d, want 1", calls)
}
if !probeLocalModelAvailability(model) {
t.Fatal("cached probe result = false, want true")
}
if calls != 1 {
t.Fatalf("probe calls after immediate re-check = %d, want 1", calls)
}
now = now.Add(modelProbeSuccessBaseInterval)
if !probeLocalModelAvailability(model) {
t.Fatal("second probe result = false, want true")
}
if calls != 2 {
t.Fatalf("probe calls after success backoff window = %d, want 2", calls)
}
now = now.Add(modelProbeSuccessBaseInterval)
if !probeLocalModelAvailability(model) {
t.Fatal("cached result after doubled backoff = false, want true")
}
if calls != 2 {
t.Fatalf("probe calls before doubled backoff expires = %d, want 2", calls)
}
now = now.Add(modelProbeSuccessBaseInterval)
if !probeLocalModelAvailability(model) {
t.Fatal("third probe result = false, want true")
}
if calls != 3 {
t.Fatalf("probe calls after doubled backoff expires = %d, want 3", calls)
}
}
func TestProbeLocalModelAvailability_FailureBackoff(t *testing.T) {
resetModelProbeHooks(t)
now := time.Unix(1700000100, 0)
modelProbeNowFunc = func() time.Time { return now }
calls := 0
probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
calls++
return false
}
model := &config.ModelConfig{
ModelName: "local-vllm",
Model: "vllm/custom-model",
APIBase: "http://127.0.0.1:8000/v1",
}
if probeLocalModelAvailability(model) {
t.Fatal("first probe result = true, want false")
}
if calls != 1 {
t.Fatalf("probe calls after first failure = %d, want 1", calls)
}
if probeLocalModelAvailability(model) {
t.Fatal("cached failed probe result = true, want false")
}
if calls != 1 {
t.Fatalf("probe calls after immediate failed re-check = %d, want 1", calls)
}
now = now.Add(modelProbeFailureBaseInterval)
if probeLocalModelAvailability(model) {
t.Fatal("second failed probe result = true, want false")
}
if calls != 2 {
t.Fatalf("probe calls after failure backoff window = %d, want 2", calls)
}
now = now.Add(modelProbeFailureBaseInterval)
if probeLocalModelAvailability(model) {
t.Fatal("cached failure after doubled backoff = true, want false")
}
if calls != 2 {
t.Fatalf("probe calls before doubled failure backoff expires = %d, want 2", calls)
}
now = now.Add(modelProbeFailureBaseInterval)
if probeLocalModelAvailability(model) {
t.Fatal("third failed probe result = true, want false")
}
if calls != 3 {
t.Fatalf("probe calls after doubled failure backoff expires = %d, want 3", calls)
}
}
func TestProbeLocalModelAvailability_ResultFlipResetsBackoff(t *testing.T) {
resetModelProbeHooks(t)
now := time.Unix(1700000200, 0)
modelProbeNowFunc = func() time.Time { return now }
results := []bool{true, false, false}
index := 0
probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
if index >= len(results) {
return false
}
result := results[index]
index++
return result
}
model := &config.ModelConfig{
ModelName: "local-vllm",
Model: "vllm/custom-model",
APIBase: "http://127.0.0.1:8000/v1",
}
if !probeLocalModelAvailability(model) {
t.Fatal("first probe result = false, want true")
}
now = now.Add(modelProbeSuccessBaseInterval)
if probeLocalModelAvailability(model) {
t.Fatal("second probe result = true, want false")
}
now = now.Add(modelProbeFailureBaseInterval)
if probeLocalModelAvailability(model) {
t.Fatal("third probe result = true, want false")
}
if index != 3 {
t.Fatalf("probe invocations = %d, want 3", index)
}
}
func TestProbeLocalModelAvailability_DeduplicatesInflightProbe(t *testing.T) {
resetModelProbeHooks(t)
now := time.Unix(1700000300, 0)
modelProbeNowFunc = func() time.Time { return now }
var calls int32
probeStarted := make(chan struct{})
releaseProbe := make(chan struct{})
probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
if atomic.AddInt32(&calls, 1) == 1 {
close(probeStarted)
}
<-releaseProbe
return true
}
model := &config.ModelConfig{
ModelName: "local-vllm",
Model: "vllm/custom-model",
APIBase: "http://127.0.0.1:8000/v1",
}
const workers = 8
var wg sync.WaitGroup
results := make(chan bool, workers)
workerStarted := make(chan struct{}, workers)
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
workerStarted <- struct{}{}
results <- probeLocalModelAvailability(model)
}()
}
for range workers {
<-workerStarted
}
select {
case <-probeStarted:
case <-time.After(200 * time.Millisecond):
t.Fatal("probe did not start in time")
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("concurrent probe calls = %d, want 1", got)
}
close(releaseProbe)
wg.Wait()
close(results)
for result := range results {
if !result {
t.Fatal("deduplicated probe result = false, want true")
}
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("final probe calls = %d, want 1", got)
}
}
func TestOllamaModelMatches_WithTagRequiresExactTag(t *testing.T) {
if ollamaModelMatches("llama3:8b", "llama3:7b") {
t.Fatal("ollamaModelMatches() = true, want false for mismatched tags")
}
if !ollamaModelMatches("llama3:7b", "llama3:7b") {
t.Fatal("ollamaModelMatches() = false, want true for exact tagged match")
}
if ollamaModelMatches("llama3:8b", "llama3") {
t.Fatal("ollamaModelMatches() = true, want false when request omits tag (defaults to latest)")
}
if !ollamaModelMatches("llama3:latest", "llama3") {
t.Fatal("ollamaModelMatches() = false, want true when request omits tag and candidate is latest")
}
if !ollamaModelMatches("llama3", "llama3") {
t.Fatal("ollamaModelMatches() = false, want true when both candidate and request omit tag (latest)")
}
}

View file

@ -20,10 +20,14 @@ func resetModelProbeHooks(t *testing.T) {
origTCPProbe := probeTCPServiceFunc origTCPProbe := probeTCPServiceFunc
origOllamaProbe := probeOllamaModelFunc origOllamaProbe := probeOllamaModelFunc
origOpenAIProbe := probeOpenAICompatibleModelFunc origOpenAIProbe := probeOpenAICompatibleModelFunc
origNow := modelProbeNowFunc
resetModelProbeCache()
t.Cleanup(func() { t.Cleanup(func() {
probeTCPServiceFunc = origTCPProbe probeTCPServiceFunc = origTCPProbe
probeOllamaModelFunc = origOllamaProbe probeOllamaModelFunc = origOllamaProbe
probeOpenAICompatibleModelFunc = origOpenAIProbe probeOpenAICompatibleModelFunc = origOpenAIProbe
modelProbeNowFunc = origNow
resetModelProbeCache()
}) })
} }

View file

@ -1,40 +1,115 @@
package api package api
import ( import (
"bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv"
"strings" "strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/utils"
) )
type skillSupportResponse struct { type skillSupportResponse struct {
Skills []skills.SkillInfo `json:"skills"` Skills []skillSupportItem `json:"skills"`
} }
type skillDetailResponse struct { type skillSupportItem struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
Source string `json:"source"` Source string `json:"source"`
Description string `json:"description"` Description string `json:"description"`
OriginKind string `json:"origin_kind"`
RegistryName string `json:"registry_name,omitempty"`
RegistryURL string `json:"registry_url,omitempty"`
InstalledVersion string `json:"installed_version,omitempty"`
InstalledAt int64 `json:"installed_at,omitempty"`
}
type skillDetailResponse struct {
skillSupportItem
Content string `json:"content"` Content string `json:"content"`
} }
type skillSearchResultItem struct {
Score float64 `json:"score"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Summary string `json:"summary"`
Version string `json:"version"`
RegistryName string `json:"registry_name"`
URL string `json:"url,omitempty"`
Installed bool `json:"installed"`
InstalledName string `json:"installed_name,omitempty"`
}
type skillSearchResponse struct {
Results []skillSearchResultItem `json:"results"`
Limit int `json:"limit"`
Offset int `json:"offset"`
NextOffset int `json:"next_offset,omitempty"`
HasMore bool `json:"has_more"`
}
type installSkillRequest struct {
Slug string `json:"slug"`
Registry string `json:"registry"`
Version string `json:"version,omitempty"`
Force bool `json:"force,omitempty"`
}
type installSkillResponse struct {
Status string `json:"status"`
Slug string `json:"slug"`
Registry string `json:"registry"`
Version string `json:"version"`
Summary string `json:"summary,omitempty"`
IsSuspicious bool `json:"is_suspicious,omitempty"`
InstalledSkill *skillSupportItem `json:"skill,omitempty"`
}
type installedSkillOriginMeta struct {
Version int `json:"version"`
OriginKind string `json:"origin_kind,omitempty"`
Registry string `json:"registry,omitempty"`
Slug string `json:"slug,omitempty"`
RegistryURL string `json:"registry_url,omitempty"`
InstalledVersion string `json:"installed_version,omitempty"`
InstalledAt int64 `json:"installed_at"`
}
var ( var (
skillNameSanitizer = regexp.MustCompile(`[^a-z0-9-]+`) skillNameSanitizer = regexp.MustCompile(`[^a-z0-9-]+`)
importedSkillFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`) importedSkillFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
skillFrontmatterStripper = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`) skillFrontmatterStripper = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
persistSkillOriginMeta = writeSkillOriginMeta
workspaceSkillWriteMu sync.Mutex
errImportedSkillExists = errors.New("skill already exists")
)
const (
maxImportedSkillSize = 1 << 20
maxRegistrySearchFanout = 1000
) )
func (h *Handler) registerSkillRoutes(mux *http.ServeMux) { func (h *Handler) registerSkillRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/skills", h.handleListSkills) mux.HandleFunc("GET /api/skills", h.handleListSkills)
mux.HandleFunc("GET /api/skills/{name}", h.handleGetSkill) mux.HandleFunc("GET /api/skills/{name}", h.handleGetSkill)
mux.HandleFunc("GET /api/skills/search", h.handleSearchSkills)
mux.HandleFunc("POST /api/skills/install", h.handleInstallSkill)
mux.HandleFunc("POST /api/skills/import", h.handleImportSkill) mux.HandleFunc("POST /api/skills/import", h.handleImportSkill)
mux.HandleFunc("DELETE /api/skills/{name}", h.handleDeleteSkill) mux.HandleFunc("DELETE /api/skills/{name}", h.handleDeleteSkill)
} }
@ -46,11 +121,15 @@ func (h *Handler) handleListSkills(w http.ResponseWriter, r *http.Request) {
return return
} }
loader := newSkillsLoader(cfg.WorkspacePath()) items, err := buildSkillSupportItems(cfg)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to build skill list: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skillSupportResponse{ json.NewEncoder(w).Encode(skillSupportResponse{
Skills: loader.ListSkills(), Skills: items,
}) })
} }
@ -61,16 +140,18 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
return return
} }
loader := newSkillsLoader(cfg.WorkspacePath()) skillItems, err := buildSkillSupportItems(cfg)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to build skill list: %v", err), http.StatusInternalServerError)
return
}
name := r.PathValue("name") name := r.PathValue("name")
allSkills := loader.ListSkills() for _, skillItem := range skillItems {
if skillItem.Name != name {
for _, skill := range allSkills {
if skill.Name != name {
continue continue
} }
content, err := loadSkillContent(skill.Path) content, err := loadSkillContent(skillItem.Path)
if err != nil { if err != nil {
http.Error(w, "Skill content not found", http.StatusNotFound) http.Error(w, "Skill content not found", http.StatusNotFound)
return return
@ -78,10 +159,7 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skillDetailResponse{ json.NewEncoder(w).Encode(skillDetailResponse{
Name: skill.Name, skillSupportItem: skillItem,
Path: skill.Path,
Source: skill.Source,
Description: skill.Description,
Content: content, Content: content,
}) })
return return
@ -90,6 +168,266 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Skill not found", http.StatusNotFound) http.Error(w, "Skill not found", http.StatusNotFound)
} }
func (h *Handler) handleSearchSkills(w http.ResponseWriter, r *http.Request) {
cfg, loadErr := config.LoadConfig(h.configPath)
if loadErr != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", loadErr), http.StatusInternalServerError)
return
}
if registryErr := ensureSkillRegistryToolEnabled(cfg, "find_skills"); registryErr != nil {
http.Error(w, registryErr.Error(), http.StatusBadRequest)
return
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
limit := 20
if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" {
parsedLimit, parseErr := strconv.Atoi(rawLimit)
if parseErr != nil || parsedLimit < 1 || parsedLimit > 50 {
http.Error(w, "limit must be between 1 and 50", http.StatusBadRequest)
return
}
limit = parsedLimit
}
offset := 0
if rawOffset := strings.TrimSpace(r.URL.Query().Get("offset")); rawOffset != "" {
parsedOffset, parseErr := strconv.Atoi(rawOffset)
if parseErr != nil || parsedOffset < 0 {
http.Error(w, "offset must be 0 or greater", http.StatusBadRequest)
return
}
offset = parsedOffset
}
installedSkills, err := buildOccupiedWorkspaceSkillsByDirectory(cfg)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to inspect installed skills: %v", err), http.StatusInternalServerError)
return
}
if query == "" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skillSearchResponse{
Results: []skillSearchResultItem{},
Limit: limit,
Offset: offset,
HasMore: false,
})
return
}
registryMgr := newSkillsRegistryManager(cfg)
searchLimit := offset + limit + 1
if searchLimit > maxRegistrySearchFanout {
searchLimit = maxRegistrySearchFanout
}
results, err := registryMgr.SearchAll(r.Context(), query, searchLimit)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to search skills: %v", err), http.StatusBadGateway)
return
}
if offset > len(results) {
offset = len(results)
}
end := offset + limit
if end > len(results) {
end = len(results)
}
pageResults := results[offset:end]
response := make([]skillSearchResultItem, 0, len(pageResults))
for _, result := range pageResults {
installedSkill, installed := installedSkills[result.Slug]
item := skillSearchResultItem{
Score: result.Score,
Slug: result.Slug,
DisplayName: result.DisplayName,
Summary: result.Summary,
Version: result.Version,
RegistryName: result.RegistryName,
URL: registrySkillURL(cfg, result.RegistryName, result.Slug),
Installed: installed,
}
if installed {
item.InstalledName = installedSkill.Name
}
response = append(response, item)
}
w.Header().Set("Content-Type", "application/json")
nextOffset := 0
hasMore := len(results) > end
if hasMore {
nextOffset = end
}
json.NewEncoder(w).Encode(skillSearchResponse{
Results: response,
Limit: limit,
Offset: offset,
NextOffset: nextOffset,
HasMore: hasMore,
})
}
func (h *Handler) handleInstallSkill(w http.ResponseWriter, r *http.Request) {
cfg, loadErr := config.LoadConfig(h.configPath)
if loadErr != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", loadErr), http.StatusInternalServerError)
return
}
if registryErr := ensureSkillRegistryToolEnabled(cfg, "install_skill"); registryErr != nil {
http.Error(w, registryErr.Error(), http.StatusBadRequest)
return
}
var req installSkillRequest
if decodeErr := json.NewDecoder(r.Body).Decode(&req); decodeErr != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", decodeErr), http.StatusBadRequest)
return
}
req.Slug = strings.TrimSpace(req.Slug)
req.Registry = strings.TrimSpace(req.Registry)
req.Version = strings.TrimSpace(req.Version)
if validateErr := utils.ValidateSkillIdentifier(req.Slug); validateErr != nil {
http.Error(
w,
fmt.Sprintf("invalid slug %q: error: %s", req.Slug, validateErr.Error()),
http.StatusBadRequest,
)
return
}
if validateErr := utils.ValidateSkillIdentifier(req.Registry); validateErr != nil {
http.Error(
w,
fmt.Sprintf("invalid registry %q: error: %s", req.Registry, validateErr.Error()),
http.StatusBadRequest,
)
return
}
registryMgr := newSkillsRegistryManager(cfg)
registry := registryMgr.GetRegistry(req.Registry)
if registry == nil {
http.Error(w, fmt.Sprintf("registry %q not found", req.Registry), http.StatusBadRequest)
return
}
workspace := cfg.WorkspacePath()
skillsRoot := filepath.Join(workspace, "skills")
targetDir := filepath.Join(workspace, "skills", req.Slug)
workspaceSkillWriteMu.Lock()
defer workspaceSkillWriteMu.Unlock()
targetExists := false
if _, statErr := os.Stat(targetDir); statErr == nil {
targetExists = true
} else if !os.IsNotExist(statErr) {
http.Error(w, fmt.Sprintf("Failed to inspect install target: %v", statErr), http.StatusInternalServerError)
return
}
if !req.Force && targetExists {
http.Error(w, fmt.Sprintf("skill %q already installed at %s", req.Slug, targetDir), http.StatusConflict)
return
}
if err := os.MkdirAll(skillsRoot, 0o755); err != nil {
http.Error(w, fmt.Sprintf("Failed to create skills directory: %v", err), http.StatusInternalServerError)
return
}
stagedWorkspaceRoot, stagedTargetDir, err := createStagedSkillInstall(skillsRoot, req.Slug)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to prepare staged install: %v", err), http.StatusInternalServerError)
return
}
defer os.RemoveAll(stagedWorkspaceRoot)
result, err := registry.DownloadAndInstall(r.Context(), req.Slug, req.Version, stagedTargetDir)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to install skill: %v", err), http.StatusBadGateway)
return
}
if result.IsMalwareBlocked {
http.Error(
w,
fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", req.Slug),
http.StatusForbidden,
)
return
}
if findWorkspaceSkillInfoByDirectory(stagedWorkspaceRoot, req.Slug) == nil {
http.Error(
w,
fmt.Sprintf("Failed to install skill: registry archive for %q is not a valid skill", req.Slug),
http.StatusBadGateway,
)
return
}
installedAt := time.Now().UnixMilli()
if err := persistSkillOriginMeta(stagedTargetDir, installedSkillOriginMeta{
Version: 1,
OriginKind: "third_party",
Registry: registry.Name(),
Slug: req.Slug,
RegistryURL: registrySkillURL(cfg, registry.Name(), req.Slug),
InstalledVersion: result.Version,
InstalledAt: installedAt,
}); err != nil {
http.Error(w, fmt.Sprintf("Failed to persist skill metadata: %v", err), http.StatusInternalServerError)
return
}
if err := commitStagedSkillInstall(
stagedWorkspaceRoot,
stagedTargetDir,
targetDir,
req.Force && targetExists,
); err != nil {
http.Error(w, fmt.Sprintf("Failed to activate installed skill: %v", err), http.StatusInternalServerError)
return
}
validatedSkill := findWorkspaceSkillByDirectory(cfg, req.Slug)
if validatedSkill == nil {
http.Error(
w,
fmt.Sprintf("Failed to install skill: activated archive for %q is not a valid skill", req.Slug),
http.StatusBadGateway,
)
return
}
installedSkill := &skillSupportItem{
Name: validatedSkill.Name,
Path: validatedSkill.Path,
Source: validatedSkill.Source,
Description: validatedSkill.Description,
OriginKind: "third_party",
RegistryName: registry.Name(),
RegistryURL: registrySkillURL(cfg, registry.Name(), req.Slug),
InstalledVersion: result.Version,
InstalledAt: installedAt,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(installSkillResponse{
Status: "ok",
Slug: req.Slug,
Registry: registry.Name(),
Version: result.Version,
Summary: result.Summary,
IsSuspicious: result.IsSuspicious,
InstalledSkill: installedSkill,
})
}
func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath) cfg, err := config.LoadConfig(h.configPath)
if err != nil { if err != nil {
@ -110,54 +448,26 @@ func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) {
} }
defer uploadedFile.Close() defer uploadedFile.Close()
content, err := io.ReadAll(io.LimitReader(uploadedFile, (1<<20)+1)) content, err := io.ReadAll(io.LimitReader(uploadedFile, maxImportedSkillSize+1))
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusBadRequest)
return return
} }
if len(content) > 1<<20 { if len(content) > maxImportedSkillSize {
http.Error(w, "file exceeds 1MB limit", http.StatusBadRequest) http.Error(w, "file exceeds 1MB limit", http.StatusBadRequest)
return return
} }
workspaceSkillWriteMu.Lock()
defer workspaceSkillWriteMu.Unlock()
skillName, err := normalizeImportedSkillName(fileHeader.Filename, content) importedSkill, statusCode, err := importUploadedSkill(cfg, fileHeader.Filename, content)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), statusCode)
return return
} }
content = normalizeImportedSkillContent(content, skillName)
workspace := cfg.WorkspacePath()
skillDir := filepath.Join(workspace, "skills", skillName)
skillFile := filepath.Join(skillDir, "SKILL.md")
if _, err := os.Stat(skillDir); err == nil {
http.Error(w, "skill already exists", http.StatusConflict)
return
}
if err := os.MkdirAll(skillDir, 0o755); err != nil {
http.Error(w, fmt.Sprintf("Failed to create skill directory: %v", err), http.StatusInternalServerError)
return
}
if err := os.WriteFile(skillFile, content, 0o644); err != nil {
http.Error(w, fmt.Sprintf("Failed to save skill: %v", err), http.StatusInternalServerError)
return
}
loader := newSkillsLoader(workspace)
for _, skill := range loader.ListSkills() {
if skill.Path == skillFile || (skill.Name == skillName && skill.Source == "workspace") {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skill)
return
}
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{ json.NewEncoder(w).Encode(importedSkill)
"name": skillName,
"path": skillFile,
})
} }
func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
@ -169,6 +479,9 @@ func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
loader := newSkillsLoader(cfg.WorkspacePath()) loader := newSkillsLoader(cfg.WorkspacePath())
name := r.PathValue("name") name := r.PathValue("name")
workspaceSkillWriteMu.Lock()
defer workspaceSkillWriteMu.Unlock()
for _, skill := range loader.ListSkills() { for _, skill := range loader.ListSkills() {
if skill.Name != name { if skill.Name != name {
continue continue
@ -197,12 +510,274 @@ func newSkillsLoader(workspace string) *skills.SkillsLoader {
) )
} }
func newSkillsRegistryManager(cfg *config.Config) *skills.RegistryManager {
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
return skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
ClawHub: skills.ClawHubConfig{
Enabled: clawHubConfig.Enabled,
BaseURL: clawHubConfig.BaseURL,
AuthToken: clawHubConfig.AuthToken.String(),
SearchPath: clawHubConfig.SearchPath,
SkillsPath: clawHubConfig.SkillsPath,
DownloadPath: clawHubConfig.DownloadPath,
Timeout: clawHubConfig.Timeout,
MaxZipSize: clawHubConfig.MaxZipSize,
MaxResponseSize: clawHubConfig.MaxResponseSize,
},
})
}
func ensureSkillRegistryToolEnabled(cfg *config.Config, toolName string) error {
if !cfg.Tools.IsToolEnabled("skills") {
return fmt.Errorf("tools.skills is disabled")
}
if !cfg.Tools.IsToolEnabled(toolName) {
return fmt.Errorf("%s is disabled", toolName)
}
return nil
}
func buildSkillSupportItems(cfg *config.Config) ([]skillSupportItem, error) {
rawSkills := newSkillsLoader(cfg.WorkspacePath()).ListSkills()
items := make([]skillSupportItem, 0, len(rawSkills))
for _, skill := range rawSkills {
item, err := enrichSkillInfo(cfg, skill)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
}
func buildWorkspaceSkillItemsByDirectory(cfg *config.Config) (map[string]skillSupportItem, error) {
result := make(map[string]skillSupportItem)
items, err := buildSkillSupportItems(cfg)
if err != nil {
return nil, err
}
for _, skill := range items {
if skill.Source != "workspace" {
continue
}
dir := filepath.Base(filepath.Dir(skill.Path))
if dir == "" {
continue
}
result[dir] = skill
}
return result, nil
}
func buildOccupiedWorkspaceSkillsByDirectory(cfg *config.Config) (map[string]skillSupportItem, error) {
result := make(map[string]skillSupportItem)
items, err := buildSkillSupportItems(cfg)
if err != nil {
return nil, err
}
for _, skill := range items {
if skill.Source != "workspace" {
continue
}
key := filepath.Base(filepath.Dir(skill.Path))
if meta, err := readInstalledSkillOriginMeta(skill.Path); err == nil && meta != nil && meta.Slug != "" {
key = meta.Slug
}
if key == "" {
continue
}
result[key] = skill
}
return result, nil
}
func findWorkspaceSkillByDirectory(cfg *config.Config, directory string) *skillSupportItem {
items, err := buildWorkspaceSkillItemsByDirectory(cfg)
if err != nil {
return nil
}
skill, ok := items[directory]
if !ok {
return nil
}
return &skill
}
func findWorkspaceSkillInfoByDirectory(workspace, directory string) *skills.SkillInfo {
loader := skills.NewSkillsLoader(workspace, "", "")
for _, skill := range loader.ListSkills() {
if skill.Source != "workspace" {
continue
}
if filepath.Base(filepath.Dir(skill.Path)) != directory {
continue
}
skillCopy := skill
return &skillCopy
}
return nil
}
func createStagedSkillInstall(skillsRoot, slug string) (string, string, error) {
stagedWorkspaceRoot, err := os.MkdirTemp(skillsRoot, "."+slug+"-install-*")
if err != nil {
return "", "", err
}
stagedTargetDir := filepath.Join(stagedWorkspaceRoot, "skills", slug)
return stagedWorkspaceRoot, stagedTargetDir, nil
}
func commitStagedSkillInstall(stagedWorkspaceRoot, stagedTargetDir, targetDir string, replaceExisting bool) error {
if !replaceExisting {
return os.Rename(stagedTargetDir, targetDir)
}
backupDir, err := reserveTempDirPath(filepath.Dir(targetDir), "."+filepath.Base(targetDir)+"-backup-*")
if err != nil {
return err
}
if err := os.Rename(targetDir, backupDir); err != nil {
return fmt.Errorf("failed to move existing skill aside: %w", err)
}
if err := os.Rename(stagedTargetDir, targetDir); err != nil {
if rollbackErr := os.Rename(backupDir, targetDir); rollbackErr != nil {
return fmt.Errorf("failed to activate replacement: %w (rollback failed: %v)", err, rollbackErr)
}
return fmt.Errorf("failed to activate replacement: %w", err)
}
_ = os.RemoveAll(backupDir)
_ = os.RemoveAll(stagedWorkspaceRoot)
return nil
}
func reserveTempDirPath(parent, pattern string) (string, error) {
tempDir, err := os.MkdirTemp(parent, pattern)
if err != nil {
return "", err
}
if err := os.Remove(tempDir); err != nil {
return "", err
}
return tempDir, nil
}
func enrichSkillInfo(cfg *config.Config, skill skills.SkillInfo) (skillSupportItem, error) {
item := skillSupportItem{
Name: skill.Name,
Path: skill.Path,
Source: skill.Source,
Description: skill.Description,
OriginKind: "builtin",
}
switch skill.Source {
case "builtin":
item.OriginKind = "builtin"
case "global":
item.OriginKind = "builtin"
case "workspace":
meta, err := readInstalledSkillOriginMeta(skill.Path)
if err == nil && meta != nil {
switch meta.OriginKind {
case "manual":
item.OriginKind = "manual"
item.InstalledAt = meta.InstalledAt
case "third_party":
item.OriginKind = "third_party"
item.RegistryName = meta.Registry
item.RegistryURL = registrySkillURLFromMeta(cfg, meta)
item.InstalledVersion = meta.InstalledVersion
item.InstalledAt = meta.InstalledAt
default:
if meta.Registry != "" || meta.Slug != "" || meta.InstalledVersion != "" {
item.OriginKind = "third_party"
item.RegistryName = meta.Registry
item.RegistryURL = registrySkillURLFromMeta(cfg, meta)
item.InstalledVersion = meta.InstalledVersion
item.InstalledAt = meta.InstalledAt
} else {
item.OriginKind = "builtin"
item.InstalledAt = meta.InstalledAt
}
}
} else {
item.OriginKind = "builtin"
}
default:
item.OriginKind = "builtin"
}
return item, nil
}
func readInstalledSkillOriginMeta(skillPath string) (*installedSkillOriginMeta, error) {
metaPath := filepath.Join(filepath.Dir(skillPath), ".skill-origin.json")
data, err := os.ReadFile(metaPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var meta installedSkillOriginMeta
if err := json.Unmarshal(data, &meta); err != nil {
return nil, err
}
return &meta, nil
}
func writeSkillOriginMeta(targetDir string, meta installedSkillOriginMeta) error {
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
}
func registrySkillURL(cfg *config.Config, registryName, slug string) string {
switch registryName {
case "clawhub":
baseURL := strings.TrimRight(cfg.Tools.Skills.Registries.ClawHub.BaseURL, "/")
if baseURL == "" {
baseURL = "https://clawhub.ai"
}
return baseURL + "/skills/" + url.PathEscape(slug)
default:
return ""
}
}
func registrySkillURLFromMeta(cfg *config.Config, meta *installedSkillOriginMeta) string {
if meta == nil || meta.Slug == "" {
return ""
}
if meta.RegistryURL != "" {
return meta.RegistryURL
}
if cfg == nil || meta.Registry == "" {
return ""
}
return registrySkillURL(cfg, meta.Registry, meta.Slug)
}
func normalizeImportedSkillName(filename string, content []byte) (string, error) { func normalizeImportedSkillName(filename string, content []byte) (string, error) {
return normalizeImportedSkillNameWithHint(filename, "", content)
}
func normalizeImportedSkillNameWithHint(filename, directoryHint string, content []byte) (string, error) {
rawContent := strings.ReplaceAll(string(content), "\r\n", "\n") rawContent := strings.ReplaceAll(string(content), "\r\n", "\n")
rawContent = strings.ReplaceAll(rawContent, "\r", "\n") rawContent = strings.ReplaceAll(rawContent, "\r", "\n")
metadata, _ := extractImportedSkillMetadata(rawContent) metadata, _ := extractImportedSkillMetadata(rawContent)
raw := strings.TrimSpace(metadata["name"]) raw := strings.TrimSpace(metadata["name"])
if raw == "" {
raw = strings.TrimSpace(directoryHint)
}
if raw == "" { if raw == "" {
raw = strings.TrimSpace(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))) raw = strings.TrimSpace(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)))
} }
@ -259,6 +834,210 @@ func normalizeImportedSkillContent(content []byte, skillName string) []byte {
return []byte(builder.String()) return []byte(builder.String())
} }
func importUploadedSkill(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) {
if isImportedSkillArchive(filename, content) {
return importUploadedSkillArchive(cfg, filename, content)
}
return importUploadedMarkdownSkill(cfg, filename, content)
}
func importUploadedMarkdownSkill(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) {
skillName, err := normalizeImportedSkillName(filename, content)
if err != nil {
return nil, http.StatusBadRequest, err
}
normalizedContent := normalizeImportedSkillContent(content, skillName)
workspace := cfg.WorkspacePath()
skillDir := filepath.Join(workspace, "skills", skillName)
skillFile := filepath.Join(skillDir, "SKILL.md")
if err := ensureWorkspaceSkillDoesNotExist(skillDir); err != nil {
return nil, statusCodeForImportedSkillWriteError(err), err
}
if err := os.MkdirAll(skillDir, 0o755); err != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to create skill directory: %v", err)
}
if err := fileutil.WriteFileAtomic(skillFile, normalizedContent, 0o644); err != nil {
_ = os.RemoveAll(skillDir)
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to save skill: %v", err)
}
return finalizeImportedSkill(cfg, skillDir, skillName, false)
}
func importUploadedSkillArchive(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) {
tmpDir, tempDirErr := os.MkdirTemp("", "picoclaw-skill-import-*")
if tempDirErr != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to create temp directory: %v", tempDirErr)
}
defer os.RemoveAll(tmpDir)
archivePath := filepath.Join(tmpDir, "import.zip")
if writeErr := fileutil.WriteFileAtomic(archivePath, content, 0o600); writeErr != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to stage uploaded archive: %v", writeErr)
}
extractDir := filepath.Join(tmpDir, "extract")
if extractErr := utils.ExtractZipFile(archivePath, extractDir); extractErr != nil {
return nil, http.StatusBadRequest, fmt.Errorf("invalid ZIP archive: %w", extractErr)
}
skillRoot, err := findImportedSkillRoot(extractDir)
if err != nil {
return nil, http.StatusBadRequest, err
}
skillFile := filepath.Join(skillRoot, "SKILL.md")
skillContent, err := os.ReadFile(skillFile)
if err != nil {
return nil, http.StatusBadRequest, fmt.Errorf("failed to read SKILL.md from archive: %w", err)
}
directoryHint := ""
if filepath.Clean(skillRoot) != filepath.Clean(extractDir) {
directoryHint = filepath.Base(skillRoot)
}
skillName, err := normalizeImportedSkillNameWithHint(filename, directoryHint, skillContent)
if err != nil {
return nil, http.StatusBadRequest, err
}
workspace := cfg.WorkspacePath()
skillDir := filepath.Join(workspace, "skills", skillName)
if err := ensureWorkspaceSkillDoesNotExist(skillDir); err != nil {
return nil, statusCodeForImportedSkillWriteError(err), err
}
if err := copyImportedSkillTree(skillRoot, skillDir); err != nil {
_ = os.RemoveAll(skillDir)
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to save skill: %v", err)
}
normalizedContent := normalizeImportedSkillContent(skillContent, skillName)
if err := fileutil.WriteFileAtomic(filepath.Join(skillDir, "SKILL.md"), normalizedContent, 0o644); err != nil {
_ = os.RemoveAll(skillDir)
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to normalize skill: %v", err)
}
return finalizeImportedSkill(cfg, skillDir, skillName, true)
}
func isImportedSkillArchive(filename string, content []byte) bool {
if strings.EqualFold(filepath.Ext(filename), ".zip") {
return true
}
return len(content) >= 4 && bytes.HasPrefix(content, []byte("PK\x03\x04"))
}
func ensureWorkspaceSkillDoesNotExist(skillDir string) error {
if _, err := os.Stat(skillDir); err == nil {
return errImportedSkillExists
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to inspect skill directory: %w", err)
}
return nil
}
func statusCodeForImportedSkillWriteError(err error) int {
if err == nil {
return http.StatusOK
}
if errors.Is(err, errImportedSkillExists) {
return http.StatusConflict
}
return http.StatusInternalServerError
}
func finalizeImportedSkill(
cfg *config.Config,
skillDir string,
skillName string,
requireValidatedSkill bool,
) (*skillSupportItem, int, error) {
if err := persistSkillOriginMeta(skillDir, installedSkillOriginMeta{
Version: 1,
OriginKind: "manual",
InstalledAt: time.Now().UnixMilli(),
}); err != nil {
_ = os.RemoveAll(skillDir)
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to persist skill metadata: %v", err)
}
if importedSkill := findWorkspaceSkillByDirectory(cfg, skillName); importedSkill != nil {
return importedSkill, http.StatusOK, nil
}
if requireValidatedSkill {
_ = os.RemoveAll(skillDir)
return nil, http.StatusBadRequest, fmt.Errorf("imported archive is not a valid skill")
}
return &skillSupportItem{
Name: skillName,
Path: filepath.Join(skillDir, "SKILL.md"),
Source: "workspace",
Description: "Imported skill",
OriginKind: "manual",
}, http.StatusOK, nil
}
func findImportedSkillRoot(extractDir string) (string, error) {
skillFiles := make([]string, 0, 1)
err := filepath.WalkDir(extractDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
if d.Name() == "SKILL.md" {
skillFiles = append(skillFiles, path)
}
return nil
})
if err != nil {
return "", fmt.Errorf("failed to inspect ZIP archive: %w", err)
}
switch len(skillFiles) {
case 0:
return "", fmt.Errorf("ZIP archive must contain a SKILL.md file")
case 1:
return filepath.Dir(skillFiles[0]), nil
default:
return "", fmt.Errorf("ZIP archive must contain exactly one SKILL.md file")
}
}
func copyImportedSkillTree(srcDir, destDir string) error {
return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
relPath, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
if relPath == "." {
return os.MkdirAll(destDir, 0o755)
}
destPath := filepath.Join(destDir, relPath)
info, err := d.Info()
if err != nil {
return err
}
if d.IsDir() {
return os.MkdirAll(destPath, 0o755)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("archive contains unsupported file %q", relPath)
}
return fileutil.CopyFile(path, destPath, info.Mode().Perm())
})
}
func extractImportedSkillMetadata(raw string) (map[string]string, string) { func extractImportedSkillMetadata(raw string) (map[string]string, string) {
matches := importedSkillFrontmatter.FindStringSubmatch(raw) matches := importedSkillFrontmatter.FindStringSubmatch(raw)
if len(matches) != 2 { if len(matches) != 2 {

File diff suppressed because it is too large Load diff

View file

@ -5,22 +5,60 @@ export interface SkillSupportItem {
path: string path: string
source: "workspace" | "global" | "builtin" | string source: "workspace" | "global" | "builtin" | string
description: string description: string
origin_kind: "builtin" | "third_party" | "manual" | string
registry_name?: string
registry_url?: string
installed_version?: string
installed_at?: number
} }
export interface SkillDetailResponse extends SkillSupportItem { export interface SkillDetailResponse extends SkillSupportItem {
content: string content: string
} }
export interface SkillRegistrySearchResult {
score: number
slug: string
display_name: string
summary: string
version: string
registry_name: string
url?: string
installed: boolean
installed_name?: string
}
interface SkillsResponse { interface SkillsResponse {
skills: SkillSupportItem[] skills: SkillSupportItem[]
} }
interface SkillActionResponse { export interface SkillSearchResponse {
results: SkillRegistrySearchResult[]
limit: number
offset: number
next_offset?: number
has_more: boolean
}
type SkillActionResponse = Partial<SkillSupportItem> & {
status?: string status?: string
name?: string }
path?: string
source?: string export interface InstallSkillRequest {
description?: string slug: string
registry: string
version?: string
force?: boolean
}
export interface InstallSkillResponse {
status: string
slug: string
registry: string
version: string
summary?: string
is_suspicious?: boolean
skill?: SkillSupportItem
} }
async function request<T>(path: string, options?: RequestInit): Promise<T> { async function request<T>(path: string, options?: RequestInit): Promise<T> {
@ -39,6 +77,29 @@ export async function getSkill(name: string): Promise<SkillDetailResponse> {
return request<SkillDetailResponse>(`/api/skills/${encodeURIComponent(name)}`) return request<SkillDetailResponse>(`/api/skills/${encodeURIComponent(name)}`)
} }
export async function searchSkills(
query: string,
limit = 20,
offset = 0,
): Promise<SkillSearchResponse> {
const params = new URLSearchParams({
q: query,
limit: String(limit),
offset: String(offset),
})
return request<SkillSearchResponse>(`/api/skills/search?${params.toString()}`)
}
export async function installSkill(
input: InstallSkillRequest,
): Promise<InstallSkillResponse> {
return request<InstallSkillResponse>("/api/skills/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
})
}
export async function importSkill(file: File): Promise<SkillActionResponse> { export async function importSkill(file: File): Promise<SkillActionResponse> {
const formData = new FormData() const formData = new FormData()
formData.set("file", file) formData.set("file", file)
@ -64,7 +125,12 @@ export async function deleteSkill(name: string): Promise<SkillActionResponse> {
async function extractErrorMessage(res: Response): Promise<string> { async function extractErrorMessage(res: Response): Promise<string> {
try { try {
const body = (await res.json()) as { const raw = await res.text()
if (raw.trim() === "") {
return `API error: ${res.status} ${res.statusText}`
}
try {
const body = JSON.parse(raw) as {
error?: string error?: string
errors?: string[] errors?: string[]
} }
@ -74,6 +140,9 @@ async function extractErrorMessage(res: Response): Promise<string> {
if (typeof body.error === "string" && body.error.trim() !== "") { if (typeof body.error === "string" && body.error.trim() !== "") {
return body.error return body.error
} }
} catch {
return raw.trim()
}
} catch { } catch {
// ignore invalid body // ignore invalid body
} }

View file

@ -0,0 +1,51 @@
import { useTranslation } from "react-i18next"
import { PageHeader } from "@/components/page-header"
import { ResultsPanel } from "./results-panel"
import { SearchPanel } from "./search-panel"
import { useHubMarketplace } from "./use-hub-marketplace"
export function HubPage() {
const { t } = useTranslation()
const hub = useHubMarketplace()
return (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.hub")} />
<div
className="flex-1 overflow-auto px-6 py-6"
onScroll={hub.handleScroll}
>
<div className="mx-auto w-full max-w-[1000px] space-y-8">
<section className="animate-in fade-in mx-auto flex w-full flex-col items-center space-y-8 duration-300 md:duration-500">
<SearchPanel
marketQuery={hub.marketQuery}
canSearchMarketplace={hub.canSearchMarketplace}
isMarketSearchInitialLoading={hub.isMarketSearchInitialLoading}
unavailableToolMessages={hub.unavailableToolMessages}
onMarketQueryChange={hub.setMarketQuery}
onSearchSubmit={hub.handleSearchSubmit}
/>
<ResultsPanel
canSearchMarketplace={hub.canSearchMarketplace}
hasSubmittedQuery={hub.hasSubmittedQuery}
submittedQuery={hub.submittedMarketQuery}
marketResults={hub.marketResults}
marketSearchError={hub.marketSearchError}
isMarketSearchInitialLoading={hub.isMarketSearchInitialLoading}
isMarketSearchLoadingMore={hub.isMarketSearchLoadingMore}
canInstallFromMarketplace={hub.canInstallFromMarketplace}
getInstalledSkill={hub.getInstalledSkill}
isInstallPending={hub.isInstallPending}
onInstall={hub.handleInstall}
onViewInstalled={hub.handleViewInstalled}
/>
</section>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,132 @@
import {
IconCheck,
IconFileInfo,
IconLoader2,
IconPlus,
} from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import {
type SkillRegistrySearchResult,
type SkillSupportItem,
} from "@/api/skills"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export function MarketSkillCard({
result,
canInstall,
installPending,
installedSkill,
onInstall,
onViewInstalled,
}: {
result: SkillRegistrySearchResult
canInstall: boolean
installPending: boolean
installedSkill: SkillSupportItem | null
onInstall: () => void
onViewInstalled: () => void
}) {
const { t } = useTranslation()
return (
<Card
className="group relative overflow-hidden border-border/40 bg-card/40 transition-all hover:border-border/80 hover:bg-card hover:shadow-md"
size="sm"
>
{result.installed && (
<div className="absolute inset-x-0 top-0 h-1 bg-emerald-500/20" />
)}
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="mb-1 flex flex-wrap items-center gap-2">
<CardTitle className="text-base font-semibold tracking-tight">
{result.display_name || result.slug}
</CardTitle>
<span className="inline-flex items-center rounded-md bg-muted/60 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-muted-foreground uppercase ring-1 ring-inset ring-border/50">
{result.registry_name}
</span>
{result.installed ? (
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20">
{t("pages.agent.skills.marketplace_installed")}
</span>
) : null}
</div>
<div className="font-mono text-xs text-muted-foreground opacity-80">
{result.slug}
{result.version ? (
<span className="text-muted-foreground/60">
{" "}
· v{result.version}
</span>
) : null}
</div>
<CardDescription className="mt-2 line-clamp-2 text-sm leading-relaxed">
{result.summary}
</CardDescription>
{result.url ? (
<div className="pt-1">
<a
href={result.url}
target="_blank"
rel="noreferrer"
className="inline-flex text-xs text-primary/80 transition-colors hover:text-primary hover:underline hover:underline-offset-4"
>
{result.url}
</a>
</div>
) : null}
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<Button
size="sm"
variant={result.installed ? "secondary" : "default"}
className="shadow-sm transition-all"
disabled={!canInstall || result.installed || installPending}
onClick={onInstall}
>
{installPending ? (
<IconLoader2 className="size-4 animate-spin" />
) : result.installed ? (
<IconCheck className="size-4" />
) : (
<IconPlus className="size-4" />
)}
{result.installed
? t("pages.agent.skills.marketplace_installed")
: t("pages.agent.skills.marketplace_install_action")}
</Button>
{result.installed && installedSkill ? (
<Button
variant="outline"
size="xs"
onClick={onViewInstalled}
className="w-full shadow-sm hover:bg-muted"
>
<IconFileInfo className="mr-1 size-3.5" />
{t("pages.agent.skills.marketplace_view_installed")}
</Button>
) : null}
</div>
</div>
</CardHeader>
{result.installed_name ? (
<CardContent className="pt-0 pb-4">
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-400">
{t("pages.agent.skills.marketplace_installed_hint", {
name: result.installed_name,
})}
</div>
</CardContent>
) : null}
</Card>
)
}

View file

@ -0,0 +1,135 @@
import { IconLoader2, IconSearch, IconX } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import {
type SkillRegistrySearchResult,
type SkillSupportItem,
} from "@/api/skills"
import { MarketSkillCard } from "./market-skill-card"
export function ResultsPanel({
canSearchMarketplace,
hasSubmittedQuery,
submittedQuery,
marketResults,
marketSearchError,
isMarketSearchInitialLoading,
isMarketSearchLoadingMore,
canInstallFromMarketplace,
getInstalledSkill,
isInstallPending,
onInstall,
onViewInstalled,
}: {
canSearchMarketplace: boolean
hasSubmittedQuery: boolean
submittedQuery: string
marketResults: SkillRegistrySearchResult[]
marketSearchError: unknown
isMarketSearchInitialLoading: boolean
isMarketSearchLoadingMore: boolean
canInstallFromMarketplace: boolean
getInstalledSkill: (installedName?: string) => SkillSupportItem | null
isInstallPending: (result: SkillRegistrySearchResult) => boolean
onInstall: (result: SkillRegistrySearchResult) => void
onViewInstalled: () => void
}) {
const { t } = useTranslation()
return (
<div className="mx-auto flex w-full max-w-[1000px] justify-center">
<div className="w-full">
{canSearchMarketplace && hasSubmittedQuery ? (
<div className="space-y-6">
<div className="rounded-xl border border-amber-200/80 bg-amber-50/70 px-4 py-3 text-sm text-amber-900">
<div className="font-semibold">
{t("pages.agent.skills.marketplace_notice_title")}
</div>
<div className="mt-1 leading-6">
{t("pages.agent.skills.marketplace_notice_body")}
</div>
</div>
{isMarketSearchInitialLoading ? (
<div className="border-border/40 bg-muted/10 flex min-h-[200px] flex-col items-center justify-center gap-4 rounded-xl border border-dashed">
<IconLoader2 className="text-muted-foreground/60 size-6 animate-spin" />
<span className="text-muted-foreground text-sm font-medium">
{t("pages.agent.skills.marketplace_loading_results")}
</span>
</div>
) : marketSearchError ? (
<div className="border-destructive/20 bg-destructive/5 rounded-xl border px-6 py-5">
<div className="text-destructive flex items-center gap-3">
<IconX className="size-5" />
<span className="text-sm font-medium">
{marketSearchError instanceof Error
? marketSearchError.message
: t("pages.agent.skills.marketplace_search_error")}
</span>
</div>
</div>
) : marketResults.length ? (
<div className="space-y-4">
<div className="border-border/40 flex items-center justify-between border-b pb-4">
<h3 className="text-foreground/85 text-base font-semibold">
{t("pages.agent.skills.marketplace_results_title", {
query: submittedQuery,
count: marketResults.length,
})}
</h3>
<span className="text-muted-foreground text-xs font-medium">
{t("pages.agent.skills.marketplace_results_hint")}
</span>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{marketResults.map((result) => (
<MarketSkillCard
key={`${result.registry_name}:${result.slug}`}
result={result}
canInstall={canInstallFromMarketplace}
installPending={isInstallPending(result)}
installedSkill={getInstalledSkill(result.installed_name)}
onInstall={() => onInstall(result)}
onViewInstalled={onViewInstalled}
/>
))}
</div>
{isMarketSearchLoadingMore ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 pt-2 text-sm">
<IconLoader2 className="size-4 animate-spin" />
<span>
{t("pages.agent.skills.marketplace_loading_more")}
</span>
</div>
) : null}
</div>
) : (
<div className="border-border/40 bg-muted/10 flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed">
<IconSearch className="text-muted-foreground/50 size-6" />
<span className="text-muted-foreground text-sm font-medium">
{t("pages.agent.skills.marketplace_empty_results", {
query: submittedQuery,
})}
</span>
</div>
)}
</div>
) : !canSearchMarketplace ? (
<div className="border-border/40 bg-muted/10 flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed">
<span className="text-muted-foreground text-sm font-medium">
{t("pages.agent.skills.marketplace_unavailable")}
</span>
</div>
) : (
<div className="border-border/40 bg-muted/10 flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed">
<IconSearch className="text-muted-foreground/50 size-6" />
<span className="text-muted-foreground text-sm font-medium">
{t("pages.agent.skills.marketplace_idle")}
</span>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,91 @@
import { IconLoader2 } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import type { UnavailableToolMessage } from "./tool-support"
export function SearchPanel({
marketQuery,
canSearchMarketplace,
isMarketSearchInitialLoading,
unavailableToolMessages,
onMarketQueryChange,
onSearchSubmit,
}: {
marketQuery: string
canSearchMarketplace: boolean
isMarketSearchInitialLoading: boolean
unavailableToolMessages: UnavailableToolMessage[]
onMarketQueryChange: (value: string) => void
onSearchSubmit: () => void
}) {
const { t } = useTranslation()
return (
<div className="flex flex-col items-center justify-center space-y-6 py-8 text-center sm:py-12">
<div className="space-y-2">
<h2 className="text-2xl font-bold tracking-tight md:text-3xl">
{t("pages.agent.skills.marketplace_title", {
defaultValue: "Discover Skills",
})}
</h2>
<p className="text-muted-foreground max-w-[600px] text-base md:text-lg">
{t("pages.agent.skills.marketplace_description")}
</p>
</div>
<form
className="w-full max-w-2xl px-4 md:px-0"
onSubmit={(event) => {
event.preventDefault()
onSearchSubmit()
}}
>
<div className="group relative flex items-center justify-center">
<Input
value={marketQuery}
onChange={(event) => onMarketQueryChange(event.target.value)}
placeholder={t("pages.agent.skills.marketplace_search_placeholder")}
className="border-border/60 bg-background/50 hover:bg-background focus-visible:ring-primary/20 h-12 w-full rounded-full pr-20 pl-5 text-sm shadow-sm backdrop-blur-sm transition-all focus-visible:ring-2 md:min-w-[520px]"
disabled={!canSearchMarketplace}
/>
<Button
type="submit"
className="absolute top-1/2 right-1.5 h-9 -translate-y-1/2 rounded-full px-4 font-medium shadow-sm transition-all"
disabled={
!canSearchMarketplace ||
isMarketSearchInitialLoading ||
marketQuery.trim() === ""
}
>
{isMarketSearchInitialLoading ? (
<IconLoader2 className="size-4 animate-spin" />
) : (
<span>
{t("pages.agent.skills.marketplace_search_action", {
defaultValue: "Search",
})}
</span>
)}
</Button>
</div>
</form>
{unavailableToolMessages.length ? (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3 pt-2">
{unavailableToolMessages.map((item) => (
<div
key={item.key}
className="rounded-xl border border-amber-200/80 bg-amber-50/70 px-4 py-3 text-left text-sm text-amber-900"
>
<div className="font-semibold">{item.label}</div>
<div className="mt-1 leading-6">{item.message}</div>
</div>
))}
</div>
) : null}
</div>
)
}

View file

@ -0,0 +1,54 @@
import type { TFunction } from "i18next"
import type { ToolSupportItem } from "@/api/tools"
type MarketplaceTool = Pick<ToolSupportItem, "status" | "reason_code"> | undefined
export interface UnavailableToolMessage {
key: "search" | "install"
label: string
message: string
}
export function buildUnavailableToolMessages({
searchTool,
installTool,
t,
}: {
searchTool: MarketplaceTool
installTool: MarketplaceTool
t: TFunction
}): UnavailableToolMessage[] {
const searchMessage = getToolSupportMessage(searchTool, t)
const installMessage = getToolSupportMessage(installTool, t)
return [
searchMessage
? {
key: "search",
label: t("pages.agent.skills.marketplace_search_status"),
message: searchMessage,
}
: null,
installMessage
? {
key: "install",
label: t("pages.agent.skills.marketplace_install_status"),
message: installMessage,
}
: null,
].filter((item): item is UnavailableToolMessage => Boolean(item))
}
function getToolSupportMessage(
tool: MarketplaceTool,
t: TFunction,
): string | null {
if (!tool || tool.status === "enabled") {
return null
}
if (tool.reason_code) {
return `${t(`pages.agent.tools.reasons.${tool.reason_code}`)} ${t("pages.agent.skills.marketplace_status_enable_hint")}`
}
return t("pages.agent.skills.marketplace_status_disabled")
}

View file

@ -0,0 +1,211 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { useEffect, useRef, useState, type UIEvent } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import {
getSkills,
installSkill,
searchSkills,
type SkillSearchResponse,
type SkillRegistrySearchResult,
type SkillSupportItem,
} from "@/api/skills"
import { getTools } from "@/api/tools"
import { buildUnavailableToolMessages } from "./tool-support"
const MARKET_SEARCH_LIMIT = 20
export function useHubMarketplace() {
const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const isLoadMoreLockedRef = useRef(false)
const [marketQuery, setMarketQuery] = useState("")
const [submittedMarketQuery, setSubmittedMarketQuery] = useState("")
const { data: skillsData } = useQuery({
queryKey: ["skills"],
queryFn: getSkills,
})
const { data: toolsData } = useQuery({
queryKey: ["tools"],
queryFn: getTools,
})
const findSkillsTool = toolsData?.tools.find(
(tool) => tool.name === "find_skills",
)
const installSkillTool = toolsData?.tools.find(
(tool) => tool.name === "install_skill",
)
const canSearchMarketplace = findSkillsTool?.status === "enabled"
const canInstallFromMarketplace = installSkillTool?.status === "enabled"
const hasSubmittedQuery = submittedMarketQuery.trim() !== ""
const isMarketSearchActive = canSearchMarketplace && hasSubmittedQuery
const {
data: marketSearchData,
isPending: isMarketSearchPending,
isFetching: isMarketSearchFetching,
isFetchingNextPage,
error: marketSearchError,
hasNextPage,
fetchNextPage,
refetch: refetchMarketSearch,
} = useInfiniteQuery({
queryKey: ["skills-marketplace", submittedMarketQuery],
initialPageParam: 0,
queryFn: ({ pageParam }) =>
searchSkills(
submittedMarketQuery,
MARKET_SEARCH_LIMIT,
Number(pageParam) || 0,
),
getNextPageParam: (lastPage: SkillSearchResponse) =>
lastPage.has_more ? lastPage.next_offset ?? undefined : undefined,
enabled: isMarketSearchActive,
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
})
const installMutation = useMutation({
mutationFn: installSkill,
onSuccess: (response) => {
toast.success(
t("pages.agent.skills.install_success", {
name: response.skill?.name ?? response.slug,
}),
)
void queryClient.invalidateQueries({ queryKey: ["skills"] })
void queryClient.invalidateQueries({ queryKey: ["skills-marketplace"] })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.skills.install_error"),
)
},
})
const allSkills = skillsData?.skills ?? []
const workspaceSkillsByName = new Map(
allSkills
.filter((skill) => skill.source === "workspace")
.map((skill) => [skill.name, skill] as const),
)
const marketResults =
marketSearchData?.pages.flatMap((page) => page.results) ?? []
const hasMoreMarketResults = hasNextPage ?? false
const isMarketSearchInitialLoading =
isMarketSearchActive &&
!marketSearchData &&
(isMarketSearchPending || isMarketSearchFetching)
const isMarketSearchLoadingMore =
isMarketSearchActive &&
Boolean(marketSearchData) &&
isFetchingNextPage
const installPendingKey =
installMutation.isPending && installMutation.variables
? `${installMutation.variables.registry}:${installMutation.variables.slug}`
: null
const unavailableToolMessages = buildUnavailableToolMessages({
searchTool: findSkillsTool,
installTool: installSkillTool,
t,
})
useEffect(() => {
if (!isFetchingNextPage) {
isLoadMoreLockedRef.current = false
}
}, [isFetchingNextPage])
const handleSearchSubmit = () => {
const nextQuery = marketQuery.trim()
if (!canSearchMarketplace || nextQuery === "") {
return
}
isLoadMoreLockedRef.current = false
if (nextQuery === submittedMarketQuery) {
void refetchMarketSearch()
return
}
setSubmittedMarketQuery(nextQuery)
}
const handleInstall = (result: SkillRegistrySearchResult) => {
installMutation.mutate({
slug: result.slug,
registry: result.registry_name,
version: result.version || undefined,
})
}
const handleViewInstalled = () => {
void navigate({ to: "/agent/skills" })
}
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
if (
!isMarketSearchActive ||
!hasMoreMarketResults ||
isFetchingNextPage ||
isLoadMoreLockedRef.current
) {
return
}
const node = event.currentTarget
const remaining = node.scrollHeight - node.scrollTop - node.clientHeight
if (remaining > 240) {
return
}
isLoadMoreLockedRef.current = true
void fetchNextPage()
}
const getInstalledSkill = (installedName?: string): SkillSupportItem | null => {
if (!installedName) {
return null
}
return workspaceSkillsByName.get(installedName) ?? null
}
const isInstallPending = (result: SkillRegistrySearchResult) =>
installPendingKey === `${result.registry_name}:${result.slug}`
return {
marketQuery,
submittedMarketQuery,
canSearchMarketplace,
canInstallFromMarketplace,
marketResults,
marketSearchError,
unavailableToolMessages,
hasSubmittedQuery,
isMarketSearchInitialLoading,
isMarketSearchLoadingMore,
setMarketQuery,
handleSearchSubmit,
handleInstall,
handleViewInstalled,
handleScroll,
getInstalledSkill,
isInstallPending,
}
}

View file

@ -0,0 +1,65 @@
import type { SkillSupportItem } from "@/api/skills"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { IconLoader2, IconTrash } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
interface DeleteDialogProps {
open: boolean
skillPendingDelete: SkillSupportItem | null
isDeletePending: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
}
export function DeleteDialog({
open,
skillPendingDelete,
isDeletePending,
onOpenChange,
onConfirm,
}: DeleteDialogProps) {
const { t } = useTranslation()
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>
{t("pages.agent.skills.delete_title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("pages.agent.skills.delete_description", {
name: skillPendingDelete?.name,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletePending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={isDeletePending || !skillPendingDelete}
onClick={onConfirm}
>
{isDeletePending ? (
<IconLoader2 className="size-4 animate-spin" />
) : (
<IconTrash className="size-4" />
)}
{t("pages.agent.skills.delete_confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -0,0 +1,249 @@
import {
IconFileCode,
IconSparkles,
IconWorld,
IconX,
} from "@tabler/icons-react"
import type { ReactNode } from "react"
import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown"
import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
import type { SkillDetailResponse, SkillSupportItem } from "@/api/skills"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import { OriginBadge } from "./origin-badge"
import {
getOriginLabel,
getSkillOriginKind,
} from "./origin-utils"
import type { SkillDetailView } from "./types"
const DETAIL_VIEWS = [
"preview",
"raw",
"meta",
] as const satisfies SkillDetailView[]
interface DetailSheetProps {
open: boolean
selectedSkill: SkillSupportItem | null
selectedSkillDetail?: SkillDetailResponse
isLoading: boolean
error: unknown
detailView: SkillDetailView
onDetailViewChange: (view: SkillDetailView) => void
onOpenChange: (open: boolean) => void
}
export function DetailSheet({
open,
selectedSkill,
selectedSkillDetail,
isLoading,
error,
detailView,
onDetailViewChange,
onOpenChange,
}: DetailSheetProps) {
const { t } = useTranslation()
const activeSkillDetail = selectedSkillDetail ?? selectedSkill
const activeSkillOrigin = activeSkillDetail
? getSkillOriginKind(activeSkillDetail)
: null
const detailLineCount = selectedSkillDetail
? selectedSkillDetail.content.split("\n").length
: 0
const detailCharacterCount = selectedSkillDetail?.content.length ?? 0
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 p-0 shadow-2xl data-[side=right]:!w-full data-[side=right]:sm:!w-[720px] data-[side=right]:sm:!max-w-[720px]"
>
<SheetHeader className="bg-muted/10 border-b px-6 py-6">
<div className="flex items-center gap-3">
<div className="bg-primary/10 string-1 ring-primary/20 text-primary flex size-10 items-center justify-center rounded-xl">
{activeSkillDetail?.origin_kind === "builtin" ? (
<IconSparkles className="size-5" />
) : activeSkillDetail?.registry_name ? (
<IconWorld className="size-5" />
) : (
<IconFileCode className="size-5" />
)}
</div>
<div className="min-w-0 flex-1 space-y-1 text-left">
<SheetTitle className="truncate text-xl font-bold tracking-tight">
{activeSkillDetail?.name || t("pages.agent.skills.viewer_title")}
</SheetTitle>
<SheetDescription className="line-clamp-2">
{activeSkillDetail?.description ||
t("pages.agent.skills.viewer_description")}
</SheetDescription>
</div>
</div>
</SheetHeader>
<div className="flex-1 overflow-x-hidden overflow-y-scroll px-6 py-6">
{isLoading ? (
<div className="space-y-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-[400px] w-full rounded-xl" />
</div>
) : error ? (
<div className="text-destructive border-destructive/20 bg-destructive/5 flex h-40 flex-col items-center justify-center gap-3 rounded-xl border">
<IconX className="size-6 opacity-80" />
<span className="text-sm font-medium">
{t("pages.agent.skills.load_detail_error")}
</span>
</div>
) : selectedSkillDetail ? (
<div className="space-y-6">
{activeSkillOrigin === "third_party" ? (
<div className="border-border/40 bg-card/40 space-y-4 rounded-xl border p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-2 px-1">
<OriginBadge
origin={activeSkillOrigin}
label={getOriginLabel(activeSkillOrigin, t)}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{selectedSkillDetail.registry_name ? (
<MetadataItem
label={t("pages.agent.skills.metadata.registry")}
value={selectedSkillDetail.registry_name}
/>
) : null}
{selectedSkillDetail.installed_version ? (
<MetadataItem
label={t("pages.agent.skills.metadata.version")}
value={selectedSkillDetail.installed_version}
/>
) : null}
{selectedSkillDetail.registry_url ? (
<MetadataItem
label={t("pages.agent.skills.metadata.url")}
value={
<a
href={selectedSkillDetail.registry_url}
target="_blank"
rel="noreferrer"
className="text-primary hover:text-primary/80 inline break-all underline-offset-4 hover:underline"
>
{selectedSkillDetail.registry_url}
</a>
}
mono
/>
) : null}
</div>
</div>
) : null}
<div className="border-border/70 bg-muted/20 inline-flex rounded-lg border p-1 shadow-sm">
{DETAIL_VIEWS.map((view) => (
<button
key={view}
type="button"
className={cn(
"rounded-md px-4 py-1.5 text-xs font-medium transition-all duration-200",
detailView === view
? "bg-background text-foreground ring-border/30 shadow-[0_1px_3px_rgba(0,0,0,0.1)] ring-1"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
onClick={() => onDetailViewChange(view)}
>
{t(`pages.agent.skills.detail_tabs.${view}`)}
</button>
))}
</div>
{detailView === "preview" ? (
<div className="prose prose-zinc dark:prose-invert prose-sm sm:prose-base prose-pre:rounded-xl prose-pre:border prose-pre:border-border/40 prose-pre:bg-zinc-950/90 prose-pre:shadow-sm prose-headings:tracking-tight prose-a:text-primary prose-a:no-underline hover:prose-a:underline max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
{selectedSkillDetail.content}
</ReactMarkdown>
</div>
) : null}
{detailView === "raw" ? (
<div className="border-border/50 overflow-x-auto rounded-xl border bg-zinc-950 p-5 shadow-sm">
<pre className="font-mono text-[13px] leading-relaxed break-words whitespace-pre-wrap text-zinc-100/90">
<code>{selectedSkillDetail.content}</code>
</pre>
</div>
) : null}
{detailView === "meta" ? (
<div className="grid gap-4 sm:grid-cols-2">
<MetadataItem
label={t("pages.agent.skills.metadata.name")}
value={selectedSkillDetail.name}
/>
<MetadataItem
label={t("pages.agent.skills.metadata.description")}
value={
selectedSkillDetail.description ||
t("pages.agent.skills.no_description")
}
/>
<MetadataItem
label={t("pages.agent.skills.metadata.lines")}
value={String(detailLineCount)}
/>
<MetadataItem
label={t("pages.agent.skills.metadata.characters")}
value={String(detailCharacterCount)}
/>
</div>
) : null}
</div>
) : null}
</div>
</SheetContent>
</Sheet>
)
}
function MetadataItem({
label,
value,
mono = false,
}: {
label: string
value: ReactNode
mono?: boolean
}) {
return (
<div className="border-border/70 bg-muted/20 rounded-xl border px-4 py-3">
<div className="text-muted-foreground text-[11px] font-semibold tracking-[0.18em] uppercase">
{label}
</div>
<div
className={cn(
"text-foreground mt-2 text-sm leading-6 break-all",
mono && "font-mono text-xs",
)}
>
{value}
</div>
</div>
)
}

View file

@ -0,0 +1,136 @@
import {
IconLayoutGrid,
IconLayoutList,
IconSearch,
} from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { getOriginLabel } from "./origin-utils"
import type { SkillLayoutMode, SkillSortOption } from "./types"
interface FilterBarProps {
searchQuery: string
sourceFilter: string
availableOrigins: string[]
sortOrder: SkillSortOption
layoutMode: SkillLayoutMode
onSearchQueryChange: (value: string) => void
onSourceFilterChange: (value: string) => void
onSortOrderChange: (value: SkillSortOption) => void
onLayoutModeChange: (value: SkillLayoutMode) => void
}
export function FilterBar({
searchQuery,
sourceFilter,
availableOrigins,
sortOrder,
layoutMode,
onSearchQueryChange,
onSourceFilterChange,
onSortOrderChange,
onLayoutModeChange,
}: FilterBarProps) {
const { t } = useTranslation()
return (
<div className="border-border/40 bg-muted/20 flex flex-wrap items-center gap-3 rounded-xl border p-2 shadow-sm">
<div className="relative min-w-[200px] flex-1">
<IconSearch className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
placeholder={t("pages.agent.skills.search_placeholder")}
className="hover:bg-background/50 focus-visible:bg-background h-9 border-transparent bg-transparent pl-9 shadow-none focus-visible:ring-1"
/>
</div>
<div className="bg-border/60 hidden h-6 w-px sm:block" />
<Select value={sourceFilter} onValueChange={onSourceFilterChange}>
<SelectTrigger className="hover:bg-background/50 focus:bg-background h-9 w-[140px] border-transparent bg-transparent shadow-none hover:ring-1 focus:ring-1">
<SelectValue placeholder={t("pages.agent.skills.source_label")} />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="all">
{t("pages.agent.skills.origin.all")}
</SelectItem>
{availableOrigins.map((origin) => (
<SelectItem key={origin} value={origin}>
{getOriginLabel(origin, t)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="bg-border/60 hidden h-6 w-px sm:block" />
<Select
value={sortOrder}
onValueChange={(value) => onSortOrderChange(value as SkillSortOption)}
>
<SelectTrigger className="hover:bg-background/50 focus:bg-background h-9 w-[160px] border-transparent bg-transparent shadow-none hover:ring-1 focus:ring-1">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
{t("pages.agent.skills.sort_label", {
defaultValue: "Sort by",
})}
:
</span>
<SelectValue />
</div>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="name-asc">
{t("pages.agent.skills.sort.name_asc")}
</SelectItem>
<SelectItem value="name-desc">
{t("pages.agent.skills.sort.name_desc")}
</SelectItem>
<SelectItem value="source">
{t("pages.agent.skills.sort.source")}
</SelectItem>
</SelectContent>
</Select>
<div className="bg-border/60 hidden h-6 w-px sm:block" />
<div className="bg-background/50 ring-border/20 inline-flex items-center rounded-lg p-0.5 shadow-sm ring-1">
<button
type="button"
className={cn(
"rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
layoutMode === "grouped"
? "bg-background text-foreground ring-border/30 shadow-sm ring-1"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => onLayoutModeChange("grouped")}
>
<IconLayoutList className="size-4" />
</button>
<button
type="button"
className={cn(
"rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
layoutMode === "grid"
? "bg-background text-foreground ring-border/30 shadow-sm ring-1"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => onLayoutModeChange("grid")}
>
<IconLayoutGrid className="size-4" />
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,160 @@
import { IconLoader2, IconUpload, IconX } from "@tabler/icons-react"
import type { DragEvent } from "react"
import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
interface ImportDialogProps {
open: boolean
isImportPending: boolean
isDragActive: boolean
onOpenChange: (open: boolean) => void
onImportClick: () => void
onDragEnter: (event: DragEvent<HTMLDivElement>) => void
onDragLeave: (event: DragEvent<HTMLDivElement>) => void
onDrop: (event: DragEvent<HTMLDivElement>) => void
}
export function ImportDialog({
open,
isImportPending,
isDragActive,
onOpenChange,
onImportClick,
onDragEnter,
onDragLeave,
onDrop,
}: ImportDialogProps) {
const { t } = useTranslation()
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!isImportPending) {
onOpenChange(nextOpen)
}
}}
>
<DialogContent
showCloseButton={false}
className="border-border/40 bg-card/95 max-w-[420px] gap-6 p-6 text-center shadow-lg backdrop-blur-sm focus:outline-none sm:rounded-2xl"
>
<div className="relative space-y-1 px-8">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground absolute top-0 right-0"
onClick={() => onOpenChange(false)}
disabled={isImportPending}
aria-label={t("common.cancel")}
title={t("common.cancel")}
>
<IconX className="size-4" />
</Button>
<DialogHeader className="space-y-1 text-center">
<DialogTitle className="text-lg font-semibold tracking-tight">
{t("pages.agent.skills.dropzone_title")}
</DialogTitle>
<DialogDescription className="text-muted-foreground text-sm">
{t("pages.agent.skills.dropzone_description")}
</DialogDescription>
</DialogHeader>
</div>
<SkillImportPanel
isDragActive={isDragActive}
isImportPending={isImportPending}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
onImportClick={onImportClick}
/>
</DialogContent>
</Dialog>
)
}
function SkillImportPanel({
isDragActive,
isImportPending,
onDragEnter,
onDragLeave,
onDrop,
onImportClick,
}: {
isDragActive: boolean
isImportPending: boolean
onDragEnter: (event: DragEvent<HTMLDivElement>) => void
onDragLeave: (event: DragEvent<HTMLDivElement>) => void
onDrop: (event: DragEvent<HTMLDivElement>) => void
onImportClick: () => void
}) {
const { t } = useTranslation()
return (
<div className="space-y-4">
<div
className={cn(
"flex min-h-48 cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed px-4 py-6 text-center transition-all duration-300",
isDragActive
? "border-primary bg-primary/10 scale-[1.02]"
: "border-border/60 bg-muted/30 hover:bg-muted/50 hover:border-primary/50",
isImportPending && "pointer-events-none opacity-50",
)}
onClick={() => {
if (!isImportPending) {
onImportClick()
}
}}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={(event) => event.preventDefault()}
onDrop={onDrop}
>
<div
className={cn(
"mb-2 rounded-full p-3 transition-colors duration-300",
isDragActive
? "bg-primary text-primary-foreground shadow-sm"
: "bg-background text-muted-foreground ring-border/50 shadow-sm ring-1",
)}
>
<IconUpload className="size-6" />
</div>
<div className="space-y-1">
<div className="text-foreground text-sm font-semibold tracking-tight">
{isDragActive
? t("pages.agent.skills.dropzone_active")
: t("pages.agent.skills.dropzone_label")}
</div>
<p className="text-muted-foreground mx-auto hidden max-w-[270px] text-xs leading-relaxed sm:block">
{isDragActive
? t("pages.agent.skills.dropzone_release")
: t("pages.agent.skills.import_constraints")}
</p>
</div>
<Button
variant="secondary"
size="sm"
className="pointer-events-none mt-2 h-8 shadow-sm"
disabled={isImportPending}
>
{isImportPending ? (
<IconLoader2 className="mr-1.5 size-3.5 animate-spin" />
) : null}
{t("pages.agent.skills.import")}
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,46 @@
import {
IconFileCode,
IconFolder,
IconSparkles,
IconWorld,
} from "@tabler/icons-react"
import { cn } from "@/lib/utils"
import { getOriginBadgeClasses } from "./origin-utils"
export function OriginBadge({
origin,
label,
}: {
origin: string
label: string
}) {
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-semibold",
getOriginBadgeClasses(origin),
)}
>
<OriginIcon origin={origin} />
{label}
</span>
)
}
export function OriginIcon({ origin }: { origin: string }) {
if (origin === "builtin") {
return <IconSparkles className="size-3.5" />
}
if (origin === "third_party") {
return <IconWorld className="size-3.5" />
}
if (origin === "manual") {
return <IconFolder className="size-3.5" />
}
if (origin === "all") {
return <IconFileCode className="size-4" />
}
return <IconFileCode className="size-3.5" />
}

View file

@ -0,0 +1,86 @@
import type { TFunction } from "i18next"
import type { SkillSupportItem } from "@/api/skills"
import type { SkillSortOption } from "./types"
const KNOWN_ORIGIN_ORDER = ["builtin", "third_party", "manual"]
export function compareSkills(
left: SkillSupportItem,
right: SkillSupportItem,
sortOrder: SkillSortOption,
) {
if (sortOrder === "source") {
const sourceDelta = compareOriginOrder(
getSkillOriginKind(left),
getSkillOriginKind(right),
)
if (sourceDelta !== 0) return sourceDelta
return left.name.localeCompare(right.name)
}
if (sortOrder === "name-desc") {
return right.name.localeCompare(left.name)
}
return left.name.localeCompare(right.name)
}
export function sortOrigins(origins: string[]) {
return [...origins].sort(compareOriginOrder)
}
export function getSkillOriginKind(skill: SkillSupportItem) {
const origin = skill.origin_kind || skill.source
return origin === "global" ? "builtin" : origin
}
export function getOriginLabel(origin: string, t: TFunction) {
if (origin === "builtin" || origin === "third_party" || origin === "manual") {
return t(`pages.agent.skills.origin.${origin}`)
}
if (origin === "all") {
return t("pages.agent.skills.origin.all")
}
return origin
}
export function getOriginAccentClasses(origin: string) {
if (origin === "manual") {
return "bg-emerald-100 text-emerald-700"
}
if (origin === "third_party") {
return "bg-sky-100 text-sky-700"
}
if (origin === "builtin") {
return "bg-amber-100 text-amber-700"
}
return "bg-muted text-muted-foreground"
}
export function getOriginBadgeClasses(origin: string) {
if (origin === "manual") {
return "bg-emerald-100 text-emerald-700"
}
if (origin === "third_party") {
return "bg-sky-100 text-sky-700"
}
if (origin === "builtin") {
return "bg-amber-100 text-amber-700"
}
return "bg-muted text-muted-foreground"
}
function compareOriginOrder(left: string, right: string) {
const leftIndex = KNOWN_ORIGIN_ORDER.indexOf(left)
const rightIndex = KNOWN_ORIGIN_ORDER.indexOf(right)
if (leftIndex !== -1 || rightIndex !== -1) {
if (leftIndex === -1) return 1
if (rightIndex === -1) return -1
return leftIndex - rightIndex
}
return left.localeCompare(right)
}

View file

@ -0,0 +1,27 @@
import { Skeleton } from "@/components/ui/skeleton"
export function PageSkeleton() {
return (
<div className="mt-4 space-y-8">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{[1, 2, 3, 4].map((index) => (
<Skeleton
key={index}
className="border-border/40 h-24 w-full rounded-xl border"
/>
))}
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48" />
</div>
<Skeleton className="h-14 w-full rounded-xl" />
<div className="grid gap-4 pt-4 lg:grid-cols-2">
{[1, 2, 3, 4].map((index) => (
<Skeleton key={index} className="h-36 w-full rounded-xl" />
))}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,84 @@
import { IconFileInfo, IconTrash } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import type { SkillSupportItem } from "@/api/skills"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
interface SkillCardProps {
skill: SkillSupportItem
onView: () => void
onDelete: () => void
}
export function SkillCard({ skill, onView, onDelete }: SkillCardProps) {
const { t } = useTranslation()
return (
<Card
className="group border-border/40 bg-card/40 hover:bg-card hover:border-border/80 relative overflow-hidden transition-all hover:shadow-md"
size="sm"
>
<div className="via-primary/10 absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-transparent to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base font-semibold tracking-tight">
{skill.name}
</CardTitle>
{skill.registry_name ? (
<span className="bg-muted/60 text-muted-foreground ring-border/50 inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-semibold tracking-wider uppercase ring-1 ring-inset">
{skill.registry_name}
</span>
) : null}
</div>
<CardDescription className="line-clamp-2 text-sm leading-relaxed">
{skill.description || t("pages.agent.skills.no_description")}
</CardDescription>
</div>
<div className="flex items-center gap-1 opacity-80 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={onView}
title={t("pages.agent.skills.view")}
>
<IconFileInfo className="size-4" />
</Button>
{skill.source === "workspace" ? (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
title={t("pages.agent.skills.delete")}
>
<IconTrash className="size-4" />
</Button>
) : null}
</div>
</div>
</CardHeader>
<CardContent>
{skill.registry_url ? (
<a
href={skill.registry_url}
target="_blank"
rel="noreferrer"
className="text-primary/80 hover:text-primary inline-flex items-center text-xs transition-colors hover:underline hover:underline-offset-4"
>
{skill.registry_url}
</a>
) : null}
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,86 @@
import { IconSearch } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import type { SkillSupportItem } from "@/api/skills"
import { OriginBadge } from "./origin-badge"
import { getOriginLabel } from "./origin-utils"
import { SkillCard } from "./skill-card"
import type { SkillGroupSection, SkillLayoutMode } from "./types"
interface SkillsListProps {
sortedSkills: SkillSupportItem[]
groupedSkills: SkillGroupSection[]
layoutMode: SkillLayoutMode
sourceFilter: string
hasActiveFilters: boolean
onViewSkill: (skill: SkillSupportItem) => void
onDeleteSkill: (skill: SkillSupportItem) => void
}
export function SkillsList({
sortedSkills,
groupedSkills,
layoutMode,
sourceFilter,
hasActiveFilters,
onViewSkill,
onDeleteSkill,
}: SkillsListProps) {
const { t } = useTranslation()
if (!sortedSkills.length) {
return (
<div className="border-border/40 bg-muted/5 flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed py-16 text-center shadow-sm">
<div className="bg-muted mb-2 rounded-full p-4">
<IconSearch className="text-muted-foreground size-6" />
</div>
<h3 className="text-lg font-semibold tracking-tight">
{hasActiveFilters
? t("pages.agent.skills.no_results")
: t("pages.agent.skills.empty")}
</h3>
</div>
)
}
if (layoutMode === "grouped" && sourceFilter === "all") {
return (
<div className="space-y-6">
{groupedSkills.map((section) => (
<div key={section.origin} className="space-y-3">
<div className="flex items-center justify-between gap-3">
<OriginBadge
origin={section.origin}
label={getOriginLabel(section.origin, t)}
/>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{section.skills.map((skill) => (
<SkillCard
key={`${skill.source}:${skill.name}`}
skill={skill}
onView={() => onViewSkill(skill)}
onDelete={() => onDeleteSkill(skill)}
/>
))}
</div>
</div>
))}
</div>
)
}
return (
<div className="grid gap-4 lg:grid-cols-2">
{sortedSkills.map((skill) => (
<SkillCard
key={`${skill.source}:${skill.name}`}
skill={skill}
onView={() => onViewSkill(skill)}
onDelete={() => onDeleteSkill(skill)}
/>
))}
</div>
)
}

View file

@ -0,0 +1,160 @@
import { IconLoader2, IconPlus } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { DeleteDialog } from "./delete-dialog"
import { DetailSheet } from "./detail-sheet"
import { FilterBar } from "./filter-bar"
import { ImportDialog } from "./import-dialog"
import { PageSkeleton } from "./page-skeleton"
import { SkillsList } from "./skills-list"
import { Stats } from "./stats"
import { useSkillsPage } from "./use-skills-page"
export function SkillsPage() {
const { t } = useTranslation()
const {
searchQuery,
sourceFilter,
sortOrder,
layoutMode,
detailView,
isDragActive,
isImportDialogOpen,
selectedSkill,
skillPendingDelete,
availableOrigins,
groupedSkills,
stats,
sortedSkills,
hasActiveFilters,
importInputRef,
selectedSkillDetail,
skillsError,
skillDetailError,
isLoading,
isSkillDetailLoading,
isImportPending,
isDeletePending,
setSearchQuery,
setSourceFilter,
setSortOrder,
setLayoutMode,
setDetailView,
openImportDialog,
handleViewSkill,
handleRequestDelete,
handleConfirmDelete,
handleImportClick,
handleImportFileChange,
handleDropZoneDragEnter,
handleDropZoneDragLeave,
handleDropZoneDrop,
handleDetailSheetOpenChange,
handleImportDialogOpenChange,
handleDeleteDialogOpenChange,
} = useSkillsPage()
return (
<div className="flex h-full flex-col">
<PageHeader
title={t("navigation.skills")}
children={
<>
<input
ref={importInputRef}
type="file"
accept=".md,.zip,text/markdown,text/plain,application/zip,application/x-zip-compressed"
className="hidden"
onChange={handleImportFileChange}
/>
<Button
variant="outline"
onClick={openImportDialog}
disabled={isImportPending}
>
{isImportPending ? (
<IconLoader2 className="size-4 animate-spin" />
) : (
<IconPlus className="size-4" />
)}
{t("pages.agent.skills.import")}
</Button>
</>
}
/>
<div className="flex-1 overflow-auto px-6 py-6">
<div className="w-full max-w-6xl space-y-8">
{isLoading ? (
<PageSkeleton />
) : skillsError ? (
<div className="text-destructive py-6 text-sm">
{t("pages.agent.load_error")}
</div>
) : (
<section className="animate-in fade-in space-y-3 duration-300 md:duration-500">
<Stats stats={stats} />
<div className="flex flex-col gap-4 py-3">
<FilterBar
searchQuery={searchQuery}
sourceFilter={sourceFilter}
availableOrigins={availableOrigins}
sortOrder={sortOrder}
layoutMode={layoutMode}
onSearchQueryChange={setSearchQuery}
onSourceFilterChange={setSourceFilter}
onSortOrderChange={setSortOrder}
onLayoutModeChange={setLayoutMode}
/>
</div>
<SkillsList
sortedSkills={sortedSkills}
groupedSkills={groupedSkills}
layoutMode={layoutMode}
sourceFilter={sourceFilter}
hasActiveFilters={hasActiveFilters}
onViewSkill={handleViewSkill}
onDeleteSkill={handleRequestDelete}
/>
</section>
)}
</div>
</div>
<DetailSheet
open={selectedSkill !== null}
selectedSkill={selectedSkill}
selectedSkillDetail={selectedSkillDetail}
isLoading={isSkillDetailLoading}
error={skillDetailError}
detailView={detailView}
onDetailViewChange={setDetailView}
onOpenChange={handleDetailSheetOpenChange}
/>
<ImportDialog
open={isImportDialogOpen}
isImportPending={isImportPending}
isDragActive={isDragActive}
onOpenChange={handleImportDialogOpenChange}
onImportClick={handleImportClick}
onDragEnter={handleDropZoneDragEnter}
onDragLeave={handleDropZoneDragLeave}
onDrop={handleDropZoneDrop}
/>
<DeleteDialog
open={skillPendingDelete !== null}
skillPendingDelete={skillPendingDelete}
isDeletePending={isDeletePending}
onOpenChange={handleDeleteDialogOpenChange}
onConfirm={handleConfirmDelete}
/>
</div>
)
}

View file

@ -0,0 +1,39 @@
import { Card, CardContent } from "@/components/ui/card"
import { cn } from "@/lib/utils"
import { OriginIcon } from "./origin-badge"
import { getOriginAccentClasses } from "./origin-utils"
import type { SkillStatItem } from "./types"
export function Stats({ stats }: { stats: SkillStatItem[] }) {
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{stats.map((stat) => (
<Card
key={stat.key}
size="sm"
className="border-border/40 bg-card/40 hover:bg-card gap-3 shadow-sm transition-all hover:shadow-md"
>
<CardContent className="flex items-center justify-between pt-4">
<div className="space-y-1">
<div className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase">
{stat.label}
</div>
<div className="text-2xl font-bold tracking-tight">
{stat.count}
</div>
</div>
<div
className={cn(
"rounded-xl p-2.5 shadow-sm ring-1 ring-white/10 ring-inset",
getOriginAccentClasses(stat.origin),
)}
>
<OriginIcon origin={stat.origin} />
</div>
</CardContent>
</Card>
))}
</div>
)
}

View file

@ -0,0 +1,17 @@
import type { SkillSupportItem } from "@/api/skills"
export type SkillSortOption = "name-asc" | "name-desc" | "source"
export type SkillLayoutMode = "grouped" | "grid"
export type SkillDetailView = "preview" | "raw" | "meta"
export interface SkillGroupSection {
origin: string
skills: SkillSupportItem[]
}
export interface SkillStatItem {
key: string
origin: string
label: string
count: number
}

View file

@ -0,0 +1,336 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
type ChangeEvent,
type DragEvent,
startTransition,
useDeferredValue,
useMemo,
useRef,
useState,
} from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import {
type SkillSupportItem,
deleteSkill,
getSkill,
getSkills,
importSkill,
} from "@/api/skills"
import {
compareSkills,
getOriginLabel,
getSkillOriginKind,
sortOrigins,
} from "./origin-utils"
import type {
SkillDetailView,
SkillGroupSection,
SkillLayoutMode,
SkillSortOption,
SkillStatItem,
} from "./types"
const MAX_IMPORT_FILE_SIZE = 1 << 20
export function useSkillsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const importInputRef = useRef<HTMLInputElement | null>(null)
const dragDepthRef = useRef(0)
const [searchQuery, setSearchQuery] = useState("")
const deferredSearchQuery = useDeferredValue(searchQuery)
const [sourceFilter, setSourceFilter] = useState("all")
const [sortOrder, setSortOrder] = useState<SkillSortOption>("name-asc")
const [layoutMode, setLayoutMode] = useState<SkillLayoutMode>("grouped")
const [detailView, setDetailView] = useState<SkillDetailView>("preview")
const [isDragActive, setIsDragActive] = useState(false)
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
const [selectedSkill, setSelectedSkill] = useState<SkillSupportItem | null>(
null,
)
const [skillPendingDelete, setSkillPendingDelete] =
useState<SkillSupportItem | null>(null)
const skillsQuery = useQuery({
queryKey: ["skills"],
queryFn: getSkills,
})
const skillDetailQuery = useQuery({
queryKey: ["skills", selectedSkill?.name],
queryFn: () => getSkill(selectedSkill!.name),
enabled: selectedSkill !== null,
})
const importMutation = useMutation({
mutationFn: async (file: File) => importSkill(file),
onSuccess: (importedSkill) => {
toast.success(t("pages.agent.skills.import_success"))
startTransition(() => {
setIsImportDialogOpen(false)
setDetailView("preview")
if (importedSkill.name) {
setSelectedSkill({
name: importedSkill.name,
path: importedSkill.path ?? "",
source: importedSkill.source ?? "workspace",
description: importedSkill.description ?? "",
origin_kind: importedSkill.origin_kind ?? "manual",
registry_name: importedSkill.registry_name,
registry_url: importedSkill.registry_url,
installed_version: importedSkill.installed_version,
installed_at: importedSkill.installed_at,
})
}
})
void queryClient.invalidateQueries({ queryKey: ["skills"] })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.skills.import_error"),
)
},
})
const deleteMutation = useMutation({
mutationFn: async (name: string) => deleteSkill(name),
onSuccess: (_, deletedName) => {
toast.success(t("pages.agent.skills.delete_success"))
setSkillPendingDelete(null)
if (
selectedSkill?.name === deletedName &&
selectedSkill.source === "workspace"
) {
setSelectedSkill(null)
}
void queryClient.invalidateQueries({ queryKey: ["skills"] })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.skills.delete_error"),
)
},
})
const allSkills = useMemo(
() => skillsQuery.data?.skills ?? [],
[skillsQuery.data?.skills],
)
const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase()
const availableOrigins = useMemo(
() =>
sortOrigins([
...new Set(allSkills.map((skill) => getSkillOriginKind(skill))),
]),
[allSkills],
)
const filteredSkills = useMemo(() => {
return allSkills.filter((skill) => {
const matchesSource =
sourceFilter === "all"
? true
: getSkillOriginKind(skill) === sourceFilter
if (!matchesSource) return false
if (normalizedSearchQuery === "") return true
const searchTarget =
`${skill.name} ${skill.description} ${skill.registry_name ?? ""}`.toLowerCase()
return searchTarget.includes(normalizedSearchQuery)
})
}, [allSkills, normalizedSearchQuery, sourceFilter])
const sortedSkills = useMemo(
() => [...filteredSkills].sort((left, right) => compareSkills(left, right, sortOrder)),
[filteredSkills, sortOrder],
)
const groupedSkills = useMemo<SkillGroupSection[]>(
() =>
availableOrigins
.map((origin) => ({
origin,
skills: sortedSkills.filter(
(skill) => getSkillOriginKind(skill) === origin,
),
}))
.filter((section) => section.skills.length > 0),
[availableOrigins, sortedSkills],
)
const stats = useMemo<SkillStatItem[]>(
() => [
{
key: "all",
origin: "all",
label: t("pages.agent.skills.summary.total"),
count: allSkills.length,
},
...availableOrigins.map((origin) => ({
key: origin,
origin,
label: getOriginLabel(origin, t),
count: allSkills.filter((skill) => getSkillOriginKind(skill) === origin)
.length,
})),
],
[allSkills, availableOrigins, t],
)
const hasActiveFilters =
normalizedSearchQuery !== "" || sourceFilter !== "all"
const handleImportClick = () => {
importInputRef.current?.click()
}
const handleViewSkill = (skill: SkillSupportItem) => {
setDetailView("preview")
setSelectedSkill(skill)
}
const handleRequestDelete = (skill: SkillSupportItem) => {
setSkillPendingDelete(skill)
}
const handleConfirmDelete = () => {
if (skillPendingDelete) {
deleteMutation.mutate(skillPendingDelete.name)
}
}
const handleDetailSheetOpenChange = (open: boolean) => {
if (!open) {
setSelectedSkill(null)
}
}
const handleImportDialogOpenChange = (open: boolean) => {
if (!importMutation.isPending) {
setIsImportDialogOpen(open)
}
}
const handleDeleteDialogOpenChange = (open: boolean) => {
if (!open) {
setSkillPendingDelete(null)
}
}
const validateImportFile = (file: File) => {
const fileName = file.name.toLowerCase()
const isMarkdownFile =
fileName.endsWith(".md") ||
file.type === "text/markdown" ||
file.type === "text/plain" ||
file.type === ""
const isZipFile =
fileName.endsWith(".zip") ||
file.type === "application/zip" ||
file.type === "application/x-zip-compressed"
if (!isMarkdownFile && !isZipFile) {
return t("pages.agent.skills.import_invalid_type")
}
if (file.size > MAX_IMPORT_FILE_SIZE) {
return t("pages.agent.skills.import_invalid_size")
}
return null
}
const handleImportFile = (file: File) => {
const validationMessage = validateImportFile(file)
if (validationMessage) {
toast.error(validationMessage)
return
}
importMutation.mutate(file)
}
const handleImportFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
handleImportFile(file)
event.target.value = ""
}
const resetDragState = () => {
dragDepthRef.current = 0
setIsDragActive(false)
}
const handleDropZoneDragEnter = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
dragDepthRef.current += 1
setIsDragActive(true)
}
const handleDropZoneDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) {
setIsDragActive(false)
}
}
const handleDropZoneDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
const file = event.dataTransfer.files?.[0]
resetDragState()
if (!file) return
handleImportFile(file)
}
return {
searchQuery,
sourceFilter,
sortOrder,
layoutMode,
detailView,
isDragActive,
isImportDialogOpen,
selectedSkill,
skillPendingDelete,
availableOrigins,
groupedSkills,
stats,
sortedSkills,
hasActiveFilters,
importInputRef,
selectedSkillDetail: skillDetailQuery.data,
skillsError: skillsQuery.error,
skillDetailError: skillDetailQuery.error,
isLoading: skillsQuery.isLoading,
isSkillDetailLoading: skillDetailQuery.isLoading,
isImportPending: importMutation.isPending,
isDeletePending: deleteMutation.isPending,
setSearchQuery,
setSourceFilter,
setSortOrder,
setLayoutMode,
setDetailView,
openImportDialog: () => setIsImportDialogOpen(true),
handleViewSkill,
handleRequestDelete,
handleConfirmDelete,
handleImportClick,
handleImportFileChange,
handleDropZoneDragEnter,
handleDropZoneDragLeave,
handleDropZoneDrop,
handleDetailSheetOpenChange,
handleImportDialogOpenChange,
handleDeleteDialogOpenChange,
}
}

View file

@ -0,0 +1,288 @@
import { IconSearch } from "@tabler/icons-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { type ToolSupportItem, getTools, setToolEnabled } from "@/api/tools"
import { PageHeader } from "@/components/page-header"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { refreshGatewayState } from "@/store/gateway"
export function ToolsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data, isLoading, error } = useQuery({
queryKey: ["tools"],
queryFn: getTools,
})
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const toggleMutation = useMutation({
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
setToolEnabled(name, enabled),
onSuccess: (_, variables) => {
toast.success(
variables.enabled
? t("pages.agent.tools.enable_success")
: t("pages.agent.tools.disable_success"),
)
void queryClient.invalidateQueries({ queryKey: ["tools"] })
void refreshGatewayState({ force: true })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.tools.toggle_error"),
)
},
})
// Filter and group tools
const { groupedTools, totalFilteredCount } = useMemo(() => {
if (!data) return { groupedTools: [], totalFilteredCount: 0 }
let count = 0
const buckets = new Map<string, ToolSupportItem[]>()
for (const item of data.tools) {
// Apply status filter
if (statusFilter !== "all" && item.status !== statusFilter) continue
// Apply search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase()
const matchesName = item.name.toLowerCase().includes(query)
const matchesDesc = (item.description || "")
.toLowerCase()
.includes(query)
if (!matchesName && !matchesDesc) continue
}
count++
const list = buckets.get(item.category) ?? []
list.push(item)
buckets.set(item.category, list)
}
return {
groupedTools: Array.from(buckets.entries()),
totalFilteredCount: count,
}
}, [data, searchQuery, statusFilter])
return (
<div className="bg-background flex h-full flex-col">
<PageHeader title={t("navigation.tools")} />
<div className="flex-1 overflow-auto px-6 py-6">
<div className="mx-auto w-full max-w-6xl space-y-8">
{/* Header & Description */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-end">
{/* Filters Toolbar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative">
<IconSearch className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
type="text"
placeholder={t("pages.agent.tools.search_placeholder")}
className="w-full pl-9 sm:w-64"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-40">
<SelectValue
placeholder={t("pages.agent.tools.filter.all")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("pages.agent.tools.filter.all")}
</SelectItem>
<SelectItem value="enabled">
{t("pages.agent.tools.filter.enabled")}
</SelectItem>
<SelectItem value="disabled">
{t("pages.agent.tools.filter.disabled")}
</SelectItem>
<SelectItem value="blocked">
{t("pages.agent.tools.filter.blocked")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Content Area */}
{error ? (
<Card className="border-destructive/50 bg-destructive/10 cursor-default">
<CardContent className="py-10 text-center">
<p className="text-destructive font-medium">
{t("pages.agent.load_error")}
</p>
</CardContent>
</Card>
) : isLoading ? (
// Skeleton Loading State
<div className="space-y-8">
{[1, 2].map((groupIndex) => (
<div key={groupIndex} className="space-y-4">
<Skeleton className="h-5 w-32" />
<div className="grid gap-4 lg:grid-cols-2">
{[1, 2, 3, 4].map((itemIndex) => (
<Card
key={itemIndex}
className="border-border/60 shadow-none"
>
<CardHeader className="pb-3">
<Skeleton className="mb-2 h-5 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</CardHeader>
<CardContent>
<Skeleton className="mt-2 h-8 w-full rounded-md" />
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
) : totalFilteredCount === 0 ? (
// Empty State
<Card className="bg-muted/30 cursor-default border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center text-sm">
<div className="bg-muted mb-4 rounded-full p-4">
<IconSearch className="text-muted-foreground size-8" />
</div>
<h3 className="mb-1 text-lg font-medium">
{data?.tools.length === 0
? t("pages.agent.tools.empty")
: t("pages.agent.tools.no_results")}
</h3>
{data?.tools.length !== 0 && (
<p className="text-muted-foreground">
Try adjusting your search criteria or status filters.
</p>
)}
</CardContent>
</Card>
) : (
// Tool Categories list
<div className="space-y-8">
{groupedTools.map(([category, items]) => (
<div key={category} className="space-y-4">
<h3 className="text-foreground text-sm font-semibold tracking-wide uppercase">
{t(`pages.agent.tools.categories.${category}`)}
</h3>
<div className="grid gap-4 lg:grid-cols-2">
{items.map((tool) => {
const reasonText = tool.reason_code
? t(`pages.agent.tools.reasons.${tool.reason_code}`)
: ""
const isPending =
toggleMutation.isPending &&
toggleMutation.variables?.name === tool.name
const isEnabled = tool.status === "enabled"
const isDisabled = tool.status === "disabled"
const isBlocked = tool.status === "blocked"
return (
<Card
key={tool.name}
className={cn(
"group cursor-default transition-colors",
isBlocked
? "border-amber-200/80 bg-amber-50/60 dark:border-amber-900/50 dark:bg-amber-950/20"
: "border-border/60",
isDisabled && "opacity-80",
)}
>
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<CardTitle className="font-mono text-sm font-semibold break-all">
{tool.name}
</CardTitle>
<ToolStatusBadge status={tool.status} />
</div>
<CardDescription className="text-muted-foreground/80 mt-2 text-xs leading-relaxed break-words sm:text-sm">
{tool.description}
</CardDescription>
</div>
<div className="flex shrink-0 items-center pt-1 pl-2 sm:pt-0">
<Switch
checked={isEnabled}
disabled={isPending}
onCheckedChange={(checked) =>
toggleMutation.mutate({
name: tool.name,
enabled: checked,
})
}
/>
</div>
</div>
</CardHeader>
{reasonText && (
<CardContent className="pt-0 pb-4">
<div className="text-xs font-medium text-amber-700 dark:text-amber-400">
{reasonText}
</div>
</CardContent>
)}
</Card>
)
})}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}
function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) {
const { t } = useTranslation()
return (
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium tracking-wide sm:text-[11px]",
status === "enabled" &&
"bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400",
status === "blocked" &&
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
status === "disabled" && "bg-muted text-muted-foreground",
)}
>
{t(`pages.agent.tools.status.${status}`)}
</span>
)
}

View file

@ -6,6 +6,7 @@ import {
IconKey, IconKey,
IconListDetails, IconListDetails,
IconMessageCircle, IconMessageCircle,
IconSearch,
IconSettings, IconSettings,
IconSparkles, IconSparkles,
IconTools, IconTools,
@ -24,14 +25,15 @@ import {
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarFooter,
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
SidebarGroupLabel, SidebarGroupLabel,
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarFooter,
SidebarMenuItem, SidebarMenuItem,
SidebarRail, SidebarRail,
useSidebar,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import { useSidebarChannels } from "@/hooks/use-sidebar-channels" import { useSidebarChannels } from "@/hooks/use-sidebar-channels"
@ -71,6 +73,7 @@ const baseNavGroups: Omit<NavGroup, "items">[] = [
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const routerState = useRouterState() const routerState = useRouterState()
const { i18n, t } = useTranslation() const { i18n, t } = useTranslation()
const { isMobile, setOpenMobile } = useSidebar()
const currentPath = routerState.location.pathname const currentPath = routerState.location.pathname
const { const {
channelItems, channelItems,
@ -88,6 +91,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}) })
const versionText = versionInfo?.version ?? t("footer.version_unknown") const versionText = versionInfo?.version ?? t("footer.version_unknown")
const handleNavItemClick = React.useCallback(() => {
if (isMobile) {
setOpenMobile(false)
}
}, [isMobile, setOpenMobile])
const navGroups: NavGroup[] = React.useMemo(() => { const navGroups: NavGroup[] = React.useMemo(() => {
return [ return [
@ -133,6 +141,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{ {
...baseNavGroups[2], ...baseNavGroups[2],
items: [ items: [
{
title: "navigation.hub",
url: "/agent/hub",
icon: IconSearch,
translateTitle: true,
},
{ {
title: "navigation.skills", title: "navigation.skills",
url: "/agent/skills", url: "/agent/skills",
@ -199,7 +213,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuButton <SidebarMenuButton
asChild asChild
isActive={isActive} isActive={isActive}
data-tour={item.url === "/models" ? "models-nav" : undefined} onClick={handleNavItemClick}
data-tour={
item.url === "/models" ? "models-nav" : undefined
}
className={`h-9 px-3 ${isActive ? "bg-accent/80 text-foreground font-medium" : "text-muted-foreground hover:bg-muted/60"}`} className={`h-9 px-3 ${isActive ? "bg-accent/80 text-foreground font-medium" : "text-muted-foreground hover:bg-muted/60"}`}
> >
<Link to={item.url}> <Link to={item.url}>
@ -246,7 +263,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</Collapsible> </Collapsible>
))} ))}
</SidebarContent> </SidebarContent>
<SidebarFooter className="border-t-border/30 group-data-[collapsible=icon]:hidden border-t px-3 py-2"> <SidebarFooter className="border-t-border/30 border-t px-3 py-2 group-data-[collapsible=icon]:hidden">
<div className="text-muted-foreground flex flex-col gap-0.5 text-[11px] leading-4"> <div className="text-muted-foreground flex flex-col gap-0.5 text-[11px] leading-4">
<div className="truncate" title={versionText}> <div className="truncate" title={versionText}>
<span className="text-foreground/80">{t("footer.version")}:</span>{" "} <span className="text-foreground/80">{t("footer.version")}:</span>{" "}

View file

@ -1,319 +0,0 @@
import {
IconFileInfo,
IconLoader2,
IconPlus,
IconTrash,
} from "@tabler/icons-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { type ChangeEvent, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown"
import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
import { toast } from "sonner"
import {
type SkillSupportItem,
deleteSkill,
getSkill,
getSkills,
importSkill,
} from "@/api/skills"
import { PageHeader } from "@/components/page-header"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
export function SkillsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const importInputRef = useRef<HTMLInputElement | null>(null)
const [selectedSkill, setSelectedSkill] = useState<SkillSupportItem | null>(
null,
)
const [skillPendingDelete, setSkillPendingDelete] =
useState<SkillSupportItem | null>(null)
const { data, isLoading, error } = useQuery({
queryKey: ["skills"],
queryFn: getSkills,
})
const {
data: selectedSkillDetail,
isLoading: isSkillDetailLoading,
error: skillDetailError,
} = useQuery({
queryKey: ["skills", selectedSkill?.name],
queryFn: () => getSkill(selectedSkill!.name),
enabled: selectedSkill !== null,
})
const importMutation = useMutation({
mutationFn: async (file: File) => importSkill(file),
onSuccess: () => {
toast.success(t("pages.agent.skills.import_success"))
void queryClient.invalidateQueries({ queryKey: ["skills"] })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.skills.import_error"),
)
},
})
const deleteMutation = useMutation({
mutationFn: async (name: string) => deleteSkill(name),
onSuccess: (_, deletedName) => {
toast.success(t("pages.agent.skills.delete_success"))
setSkillPendingDelete(null)
if (
selectedSkill?.name === deletedName &&
selectedSkill.source === "workspace"
) {
setSelectedSkill(null)
}
void queryClient.invalidateQueries({ queryKey: ["skills"] })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.skills.delete_error"),
)
},
})
const handleImportClick = () => {
importInputRef.current?.click()
}
const handleImportFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
importMutation.mutate(file)
event.target.value = ""
}
return (
<div className="flex h-full flex-col">
<PageHeader
title={t("navigation.skills")}
children={
<>
<input
ref={importInputRef}
type="file"
accept=".md,text/markdown,text/plain"
className="hidden"
onChange={handleImportFileChange}
/>
<Button
variant="outline"
onClick={handleImportClick}
disabled={importMutation.isPending}
>
{importMutation.isPending ? (
<IconLoader2 className="size-4 animate-spin" />
) : (
<IconPlus className="size-4" />
)}
{t("pages.agent.skills.import")}
</Button>
</>
}
/>
<div className="flex-1 overflow-auto px-6 py-3">
<div className="w-full max-w-6xl space-y-6">
{isLoading ? (
<div className="text-muted-foreground py-6 text-sm">
{t("labels.loading")}
</div>
) : error ? (
<div className="text-destructive py-6 text-sm">
{t("pages.agent.load_error")}
</div>
) : (
<section className="space-y-5">
<p className="text-muted-foreground text-sm">
{t("pages.agent.skills.description")}
</p>
{data?.skills.length ? (
<div className="grid gap-4 lg:grid-cols-2">
{data.skills.map((skill) => (
<Card
key={`${skill.source}:${skill.name}`}
className="border-border/60 gap-4"
size="sm"
>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="font-semibold">
{skill.name}
</CardTitle>
<CardDescription className="mt-3">
{skill.description ||
t("pages.agent.skills.no_description")}
</CardDescription>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => setSelectedSkill(skill)}
title={t("pages.agent.skills.view")}
>
<IconFileInfo className="size-4" />
</Button>
{skill.source === "workspace" ? (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => setSkillPendingDelete(skill)}
title={t("pages.agent.skills.delete")}
>
<IconTrash className="size-4" />
</Button>
) : null}
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-muted-foreground text-[11px] tracking-[0.18em] uppercase">
{t("pages.agent.skills.path")}
</div>
<div className="bg-muted text-foreground overflow-x-auto rounded-lg px-3 py-2 font-mono text-xs leading-relaxed">
{skill.path}
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card className="border-dashed">
<CardContent className="text-muted-foreground py-10 text-center text-sm">
{t("pages.agent.skills.empty")}
</CardContent>
</Card>
)}
</section>
)}
</div>
</div>
<Sheet
open={selectedSkill !== null}
onOpenChange={(open) => {
if (!open) setSelectedSkill(null)
}}
>
<SheetContent
side="right"
className="w-full gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
>
<SheetHeader className="border-b px-6 py-5">
<SheetTitle>
{selectedSkill?.name || t("pages.agent.skills.viewer_title")}
</SheetTitle>
<SheetDescription>
{selectedSkill?.description ||
t("pages.agent.skills.viewer_description")}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-auto px-6 py-5">
{isSkillDetailLoading ? (
<div className="text-muted-foreground text-sm">
{t("pages.agent.skills.loading_detail")}
</div>
) : skillDetailError ? (
<div className="text-destructive text-sm">
{t("pages.agent.skills.load_detail_error")}
</div>
) : selectedSkillDetail ? (
<div className="space-y-5">
<div className="prose prose-sm dark:prose-invert prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
{selectedSkillDetail.content}
</ReactMarkdown>
</div>
</div>
) : null}
</div>
</SheetContent>
</Sheet>
<AlertDialog
open={skillPendingDelete !== null}
onOpenChange={(open) => {
if (!open) setSkillPendingDelete(null)
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>
{t("pages.agent.skills.delete_title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("pages.agent.skills.delete_description", {
name: skillPendingDelete?.name,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleteMutation.isPending || !skillPendingDelete}
onClick={() => {
if (skillPendingDelete)
deleteMutation.mutate(skillPendingDelete.name)
}}
>
{deleteMutation.isPending ? (
<IconLoader2 className="size-4 animate-spin" />
) : (
<IconTrash className="size-4" />
)}
{t("pages.agent.skills.delete_confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

@ -1,192 +0,0 @@
import { IconLoader2 } from "@tabler/icons-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { type ToolSupportItem, getTools, setToolEnabled } from "@/api/tools"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { cn } from "@/lib/utils"
import { refreshGatewayState } from "@/store/gateway"
export function ToolsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data, isLoading, error } = useQuery({
queryKey: ["tools"],
queryFn: getTools,
})
const toggleMutation = useMutation({
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
setToolEnabled(name, enabled),
onSuccess: (_, variables) => {
toast.success(
variables.enabled
? t("pages.agent.tools.enable_success")
: t("pages.agent.tools.disable_success"),
)
void queryClient.invalidateQueries({ queryKey: ["tools"] })
void refreshGatewayState({ force: true })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.tools.toggle_error"),
)
},
})
const groupedTools = (() => {
if (!data) return [] as Array<[string, ToolSupportItem[]]>
const buckets = new Map<string, ToolSupportItem[]>()
for (const item of data.tools) {
const list = buckets.get(item.category) ?? []
list.push(item)
buckets.set(item.category, list)
}
return Array.from(buckets.entries())
})()
return (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.tools")} />
<div className="flex-1 overflow-auto px-6 py-3">
<div className="w-full max-w-6xl space-y-6">
{isLoading ? (
<div className="text-muted-foreground py-6 text-sm">
{t("labels.loading")}
</div>
) : error ? (
<div className="text-destructive py-6 text-sm">
{t("pages.agent.load_error")}
</div>
) : (
<section className="space-y-5">
<p className="text-muted-foreground mt-1 text-sm">
{t("pages.agent.tools.description")}
</p>
{data?.tools.length ? (
groupedTools.map(([category, items]) => (
<div key={category} className="space-y-3">
<div className="text-foreground/85 text-sm font-semibold tracking-wide">
{t(`pages.agent.tools.categories.${category}`)}
</div>
<div className="grid gap-4 lg:grid-cols-2">
{items.map((tool) => {
const reasonText = tool.reason_code
? t(`pages.agent.tools.reasons.${tool.reason_code}`)
: ""
const isPending =
toggleMutation.isPending &&
toggleMutation.variables?.name === tool.name
const nextEnabled = tool.status !== "enabled"
return (
<Card
key={tool.name}
className={cn(
"gap-4 border transition-colors",
tool.status === "enabled" &&
"border-emerald-200/70 bg-emerald-50/50",
tool.status === "blocked" &&
"border-amber-200/80 bg-amber-50/60",
tool.status === "disabled" &&
"border-border/60 bg-card/70",
)}
size="sm"
>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="font-mono text-sm break-all">
{tool.name}
</CardTitle>
<CardDescription className="mt-1 break-words">
{tool.description}
</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-2 self-start">
<ToolStatusBadge status={tool.status} />
<Button
variant={
nextEnabled ? "default" : "outline"
}
size="sm"
disabled={isPending}
onClick={() =>
toggleMutation.mutate({
name: tool.name,
enabled: nextEnabled,
})
}
>
{isPending ? (
<IconLoader2 className="size-4 animate-spin" />
) : null}
{nextEnabled
? t("pages.agent.tools.enable")
: t("pages.agent.tools.disable")}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-muted-foreground text-xs">
{t("pages.agent.tools.config_key", {
key: tool.config_key,
})}
</div>
{reasonText ? (
<div className="text-sm text-amber-800">
{reasonText}
</div>
) : null}
</CardContent>
</Card>
)
})}
</div>
</div>
))
) : (
<Card className="border-dashed">
<CardContent className="text-muted-foreground py-10 text-center text-sm">
{t("pages.agent.tools.empty")}
</CardContent>
</Card>
)}
</section>
)}
</div>
</div>
</div>
)
}
function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) {
const { t } = useTranslation()
return (
<span
className={cn(
"shrink-0 rounded-md px-2 py-1 text-[11px] font-semibold",
status === "enabled" && "bg-emerald-100 text-emerald-700",
status === "blocked" && "bg-amber-100 text-amber-700",
status === "disabled" && "bg-muted text-muted-foreground",
)}
>
{t(`pages.agent.tools.status.${status}`)}
</span>
)
}

View file

@ -0,0 +1,163 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { IconX } from "@tabler/icons-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
>
<IconX
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -5,6 +5,7 @@
"models": "Models", "models": "Models",
"credentials": "Credentials", "credentials": "Credentials",
"agent_group": "Agent", "agent_group": "Agent",
"hub": "Hub",
"skills": "Skills", "skills": "Skills",
"tools": "Tools", "tools": "Tools",
"services": "Services", "services": "Services",
@ -398,11 +399,18 @@
"agent": { "agent": {
"load_error": "Failed to load agent support information.", "load_error": "Failed to load agent support information.",
"skills": { "skills": {
"description": "Skills are loaded from the workspace, global PicoClaw home, and builtin directories.",
"empty": "No skills are currently available.", "empty": "No skills are currently available.",
"install_success": "Installed {{name}}.",
"install_error": "Failed to install skill.",
"search_placeholder": "Search by name, description, or registry",
"source_label": "Type",
"sort_label": "Sort",
"import": "Import Skill", "import": "Import Skill",
"import_success": "Skill imported.", "import_success": "Skill imported.",
"import_error": "Failed to import skill.", "import_error": "Failed to import skill.",
"import_invalid_type": "Only Markdown or ZIP skill files are supported.",
"import_invalid_size": "Skill file must be 1 MB or smaller.",
"import_constraints": "Import a Markdown or ZIP skill file up to 1 MB",
"view": "View", "view": "View",
"delete": "Delete", "delete": "Delete",
"delete_title": "Delete Skill?", "delete_title": "Delete Skill?",
@ -412,20 +420,78 @@
"delete_error": "Failed to delete skill.", "delete_error": "Failed to delete skill.",
"viewer_title": "Skill Content", "viewer_title": "Skill Content",
"viewer_description": "Read the current effective SKILL.md content here.", "viewer_description": "Read the current effective SKILL.md content here.",
"loading_detail": "Loading skill content...",
"load_detail_error": "Failed to load skill content.", "load_detail_error": "Failed to load skill content.",
"path": "Skill Path", "no_description": "No description provided.",
"no_description": "No description provided." "no_results": "No skills matched the current filters.",
"dropzone_title": "Import Into Workspace",
"dropzone_description": "Drag a skill file here or pick one from disk.",
"dropzone_label": "Drop a skill file here",
"dropzone_active": "Release to import this skill",
"dropzone_release": "The skill will be normalized and saved into the workspace skills directory.",
"marketplace_title": "Discover Skills",
"marketplace_description": "Search the skill registries and install useful skills into this workspace",
"marketplace_search_placeholder": "Search for capabilities like github, docker, database...",
"marketplace_search_action": "Search",
"marketplace_search_status": "Search Status",
"marketplace_install_status": "Install Status",
"marketplace_notice_title": "Security Notice",
"marketplace_notice_body": "Registry skills are third-party content. Review the author, page URL, instructions, and any required code or credentials before installing.",
"marketplace_status_disabled": "Disabled. Enable the corresponding tool on the Tools page first.",
"marketplace_status_enable_hint": "Enable the related tool on the Tools page first.",
"marketplace_search_error": "Failed to search registries.",
"marketplace_loading_results": "Searching skills...",
"marketplace_loading_more": "Loading more skills...",
"marketplace_results_title": "{{count}} results for “{{query}}”",
"marketplace_results_hint": "Registry results install into the current workspace.",
"marketplace_install_action": "Install",
"marketplace_installed": "Installed",
"marketplace_view_installed": "View Local",
"marketplace_installed_hint": "Already available in this workspace as “{{name}}”.",
"marketplace_empty_results": "No installable skills matched “{{query}}”.",
"marketplace_idle": "Search for a capability to discover installable skills from configured registries.",
"marketplace_unavailable": "Registry search is currently unavailable. Check the Skills tools configuration.",
"sort": {
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"source": "Type"
},
"origin": {
"all": "All Types",
"builtin": "Builtin",
"third_party": "Third-Party",
"manual": "Manual"
},
"summary": {
"total": "Total Skills"
},
"detail_tabs": {
"preview": "Preview",
"raw": "Raw",
"meta": "Metadata"
},
"metadata": {
"name": "Name",
"description": "Description",
"registry": "Registry",
"url": "URL",
"version": "Installed Version",
"lines": "Line Count",
"characters": "Character Count"
}
}, },
"tools": { "tools": {
"description": "This view reflects whether each agent tool is enabled, disabled, or blocked by a missing prerequisite.", "search_placeholder": "Search tools...",
"no_results": "No tools match your criteria.",
"filter": {
"all": "All Status",
"enabled": "Enabled only",
"disabled": "Disabled only",
"blocked": "Blocked only"
},
"empty": "No tools are available.", "empty": "No tools are available.",
"enable": "Enable",
"disable": "Disable",
"enable_success": "Tool enabled.", "enable_success": "Tool enabled.",
"disable_success": "Tool disabled.", "disable_success": "Tool disabled.",
"toggle_error": "Failed to update tool state.", "toggle_error": "Failed to update tool state.",
"config_key": "Controlled by tools.{{key}}",
"status": { "status": {
"enabled": "Enabled", "enabled": "Enabled",
"disabled": "Disabled", "disabled": "Disabled",

View file

@ -5,6 +5,7 @@
"models": "模型", "models": "模型",
"credentials": "凭据", "credentials": "凭据",
"agent_group": "智能体", "agent_group": "智能体",
"hub": "Hub",
"skills": "技能", "skills": "技能",
"tools": "工具", "tools": "工具",
"services": "服务", "services": "服务",
@ -398,11 +399,18 @@
"agent": { "agent": {
"load_error": "加载 Agent 支持信息失败。", "load_error": "加载 Agent 支持信息失败。",
"skills": { "skills": {
"description": "技能会从工作区、PicoClaw 全局目录和内置目录中加载。",
"empty": "当前没有可用技能。", "empty": "当前没有可用技能。",
"install_success": "已安装 {{name}}。",
"install_error": "安装技能失败。",
"search_placeholder": "按名称、描述或技能源搜索",
"source_label": "类型",
"sort_label": "排序",
"import": "导入技能", "import": "导入技能",
"import_success": "技能导入成功。", "import_success": "技能导入成功。",
"import_error": "导入技能失败。", "import_error": "导入技能失败。",
"import_invalid_type": "仅支持导入 Markdown 或 ZIP 技能文件。",
"import_invalid_size": "技能文件大小不能超过 1 MB。",
"import_constraints": "支持导入最大 1 MB 的 Markdown 或 ZIP 文件",
"view": "查看", "view": "查看",
"delete": "删除", "delete": "删除",
"delete_title": "删除技能?", "delete_title": "删除技能?",
@ -412,20 +420,78 @@
"delete_error": "删除技能失败。", "delete_error": "删除技能失败。",
"viewer_title": "技能内容", "viewer_title": "技能内容",
"viewer_description": "这里展示当前生效的 SKILL.md 内容。", "viewer_description": "这里展示当前生效的 SKILL.md 内容。",
"loading_detail": "正在加载技能内容...",
"load_detail_error": "加载技能内容失败。", "load_detail_error": "加载技能内容失败。",
"path": "技能路径", "no_description": "未提供描述。",
"no_description": "未提供描述。" "no_results": "没有技能匹配当前筛选条件。",
"dropzone_title": "导入到工作区",
"dropzone_description": "将技能文件拖到这里,或从本地选择一个文件。",
"dropzone_label": "将技能文件拖到这里",
"dropzone_active": "松开即可导入该技能",
"dropzone_release": "导入后会自动规范化内容,并保存到工作区技能目录。",
"marketplace_title": "安装技能",
"marketplace_description": "搜索第三方技能源,并将技能安装到当前工作区",
"marketplace_search_placeholder": "搜索 github、docker、database 等技能",
"marketplace_search_action": "搜索",
"marketplace_search_status": "搜索状态",
"marketplace_install_status": "安装状态",
"marketplace_notice_title": "安全提示",
"marketplace_notice_body": "搜索结果中的 skills 属于第三方内容。安装前请先确认作者、页面 URL、说明文档以及它要求执行的代码或使用的凭据是否可信。",
"marketplace_status_disabled": "当前未启用,请先在工具页启用对应工具。",
"marketplace_status_enable_hint": "请先在工具页启用相关工具。",
"marketplace_search_error": "搜索技能源失败。",
"marketplace_loading_results": "正在搜索技能...",
"marketplace_loading_more": "正在加载更多技能...",
"marketplace_results_title": "“{{query}}” 共找到 {{count}} 个结果",
"marketplace_results_hint": "搜索结果会安装到当前工作区。",
"marketplace_install_action": "安装",
"marketplace_installed": "已安装",
"marketplace_view_installed": "查看本地技能",
"marketplace_installed_hint": "该技能已在当前工作区中可用,名称为「{{name}}」。",
"marketplace_empty_results": "没有找到与“{{query}}”匹配的可安装技能。",
"marketplace_idle": "输入一个关键词,搜索可安装的第三方技能。",
"marketplace_unavailable": "当前无法使用技能搜索,请检查 Skills 相关工具配置。",
"sort": {
"name_asc": "名称A-Z",
"name_desc": "名称Z-A",
"source": "按类型"
},
"origin": {
"all": "全部类型",
"builtin": "内置",
"third_party": "第三方",
"manual": "手动导入"
},
"summary": {
"total": "技能总数"
},
"detail_tabs": {
"preview": "预览",
"raw": "原始内容",
"meta": "元数据"
},
"metadata": {
"name": "名称",
"description": "描述",
"registry": "来源平台",
"url": "链接地址",
"version": "已安装版本",
"lines": "行数",
"characters": "字符数"
}
}, },
"tools": { "tools": {
"description": "这里展示每个 Agent 工具当前是已启用、已禁用,还是被依赖条件阻塞。", "search_placeholder": "搜索工具...",
"no_results": "没有找到符合条件的工具",
"filter": {
"all": "所有状态",
"enabled": "已启用",
"disabled": "已禁用",
"blocked": "被阻塞"
},
"empty": "当前没有可用工具。", "empty": "当前没有可用工具。",
"enable": "启用",
"disable": "禁用",
"enable_success": "工具已启用。", "enable_success": "工具已启用。",
"disable_success": "工具已禁用。", "disable_success": "工具已禁用。",
"toggle_error": "更新工具状态失败。", "toggle_error": "更新工具状态失败。",
"config_key": "由 tools.{{key}} 控制",
"status": { "status": {
"enabled": "已启用", "enabled": "已启用",
"disabled": "已禁用", "disabled": "已禁用",

View file

@ -21,6 +21,7 @@ import { Route as ConfigRawRouteImport } from './routes/config.raw'
import { Route as ChannelsNameRouteImport } from './routes/channels/$name' import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
import { Route as AgentToolsRouteImport } from './routes/agent/tools' import { Route as AgentToolsRouteImport } from './routes/agent/tools'
import { Route as AgentSkillsRouteImport } from './routes/agent/skills' import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
import { Route as AgentHubRouteImport } from './routes/agent/hub'
const ModelsRoute = ModelsRouteImport.update({ const ModelsRoute = ModelsRouteImport.update({
id: '/models', id: '/models',
@ -82,6 +83,11 @@ const AgentSkillsRoute = AgentSkillsRouteImport.update({
path: '/skills', path: '/skills',
getParentRoute: () => AgentRoute, getParentRoute: () => AgentRoute,
} as any) } as any)
const AgentHubRoute = AgentHubRouteImport.update({
id: '/hub',
path: '/hub',
getParentRoute: () => AgentRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
@ -92,6 +98,7 @@ export interface FileRoutesByFullPath {
'/launcher-login': typeof LauncherLoginRoute '/launcher-login': typeof LauncherLoginRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
'/models': typeof ModelsRoute '/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute '/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute '/agent/tools': typeof AgentToolsRoute
'/channels/$name': typeof ChannelsNameRoute '/channels/$name': typeof ChannelsNameRoute
@ -106,6 +113,7 @@ export interface FileRoutesByTo {
'/launcher-login': typeof LauncherLoginRoute '/launcher-login': typeof LauncherLoginRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
'/models': typeof ModelsRoute '/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute '/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute '/agent/tools': typeof AgentToolsRoute
'/channels/$name': typeof ChannelsNameRoute '/channels/$name': typeof ChannelsNameRoute
@ -121,6 +129,7 @@ export interface FileRoutesById {
'/launcher-login': typeof LauncherLoginRoute '/launcher-login': typeof LauncherLoginRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
'/models': typeof ModelsRoute '/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute '/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute '/agent/tools': typeof AgentToolsRoute
'/channels/$name': typeof ChannelsNameRoute '/channels/$name': typeof ChannelsNameRoute
@ -137,6 +146,7 @@ export interface FileRouteTypes {
| '/launcher-login' | '/launcher-login'
| '/logs' | '/logs'
| '/models' | '/models'
| '/agent/hub'
| '/agent/skills' | '/agent/skills'
| '/agent/tools' | '/agent/tools'
| '/channels/$name' | '/channels/$name'
@ -151,6 +161,7 @@ export interface FileRouteTypes {
| '/launcher-login' | '/launcher-login'
| '/logs' | '/logs'
| '/models' | '/models'
| '/agent/hub'
| '/agent/skills' | '/agent/skills'
| '/agent/tools' | '/agent/tools'
| '/channels/$name' | '/channels/$name'
@ -165,6 +176,7 @@ export interface FileRouteTypes {
| '/launcher-login' | '/launcher-login'
| '/logs' | '/logs'
| '/models' | '/models'
| '/agent/hub'
| '/agent/skills' | '/agent/skills'
| '/agent/tools' | '/agent/tools'
| '/channels/$name' | '/channels/$name'
@ -268,6 +280,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AgentSkillsRouteImport preLoaderRoute: typeof AgentSkillsRouteImport
parentRoute: typeof AgentRoute parentRoute: typeof AgentRoute
} }
'/agent/hub': {
id: '/agent/hub'
path: '/hub'
fullPath: '/agent/hub'
preLoaderRoute: typeof AgentHubRouteImport
parentRoute: typeof AgentRoute
}
} }
} }
@ -284,11 +303,13 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(
) )
interface AgentRouteChildren { interface AgentRouteChildren {
AgentHubRoute: typeof AgentHubRoute
AgentSkillsRoute: typeof AgentSkillsRoute AgentSkillsRoute: typeof AgentSkillsRoute
AgentToolsRoute: typeof AgentToolsRoute AgentToolsRoute: typeof AgentToolsRoute
} }
const AgentRouteChildren: AgentRouteChildren = { const AgentRouteChildren: AgentRouteChildren = {
AgentHubRoute: AgentHubRoute,
AgentSkillsRoute: AgentSkillsRoute, AgentSkillsRoute: AgentSkillsRoute,
AgentToolsRoute: AgentToolsRoute, AgentToolsRoute: AgentToolsRoute,
} }

View file

@ -15,7 +15,7 @@ function AgentLayout() {
}) })
if (pathname === "/agent") { if (pathname === "/agent") {
return <Navigate to="/agent/skills" /> return <Navigate to="/agent/hub" />
} }
return <Outlet /> return <Outlet />

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