Merge branch 'main' into feat-response
This commit is contained in:
commit
f18f15766a
330 changed files with 25720 additions and 5302 deletions
62
.github/workflows/create_dmg.yml
vendored
Normal file
62
.github/workflows/create_dmg.yml
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
name: Create macOS DMG
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build ${{ matrix.arch }}
|
||||||
|
runs-on: macos-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
# This creates two parallel jobs
|
||||||
|
arch: [arm64, amd64]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
# 1. 安装指定版本的 Go (可选,但推荐)
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
# 2. 安装 pnpm
|
||||||
|
- name: Install pnpm
|
||||||
|
run: brew install pnpm
|
||||||
|
|
||||||
|
# 3. 运行你的 Makefile 编译二进制文件
|
||||||
|
- name: Build with Make
|
||||||
|
run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }}
|
||||||
|
|
||||||
|
# 4. 签名
|
||||||
|
- name: Ad-hoc Sign
|
||||||
|
run: codesign --force --deep --sign - "build/PicoClaw Launcher.app"
|
||||||
|
|
||||||
|
# 5. 安装打包工具
|
||||||
|
- name: Install create-dmg
|
||||||
|
run: brew install create-dmg
|
||||||
|
|
||||||
|
# 6. 执行打包命令
|
||||||
|
- name: Create DMG
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
create-dmg \
|
||||||
|
--volname "PicoClaw Installer" \
|
||||||
|
--window-pos 200 120 \
|
||||||
|
--window-size 800 400 \
|
||||||
|
--icon-size 100 \
|
||||||
|
--icon "PicoClaw Launcher.app" 200 190 \
|
||||||
|
--hide-extension "PicoClaw Launcher.app" \
|
||||||
|
--app-drop-link 600 185 \
|
||||||
|
"dist/picoclaw-${{ matrix.arch }}.dmg" \
|
||||||
|
"build/PicoClaw Launcher.app"
|
||||||
|
|
||||||
|
# 7. 上传文件到 GitHub Artifacts (供你下载)
|
||||||
|
- name: Upload DMG
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: macos-dmg-${{ matrix.arch }}
|
||||||
|
path: dist/*.dmg
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
28
Makefile
28
Makefile
|
|
@ -93,13 +93,13 @@ ifeq ($(UNAME_S),Linux)
|
||||||
endif
|
endif
|
||||||
else ifeq ($(UNAME_S),Darwin)
|
else ifeq ($(UNAME_S),Darwin)
|
||||||
PLATFORM=darwin
|
PLATFORM=darwin
|
||||||
WEB_GO=CGO_ENABLED=1 go
|
WEB_GO=CGO_LDFLAGS="-mmacosx-version-min=10.11" CGO_CFLAGS="-mmacosx-version-min=10.11" CGO_ENABLED=1 go
|
||||||
ifeq ($(UNAME_M),x86_64)
|
ifeq ($(UNAME_M),x86_64)
|
||||||
ARCH=amd64
|
ARCH?=amd64
|
||||||
else ifeq ($(UNAME_M),arm64)
|
else ifeq ($(UNAME_M),arm64)
|
||||||
ARCH=arm64
|
ARCH?=arm64
|
||||||
else
|
else
|
||||||
ARCH=$(UNAME_M)
|
ARCH?=$(UNAME_M)
|
||||||
endif
|
endif
|
||||||
else
|
else
|
||||||
PLATFORM=$(UNAME_S)
|
PLATFORM=$(UNAME_S)
|
||||||
|
|
@ -122,7 +122,7 @@ generate:
|
||||||
build: generate
|
build: generate
|
||||||
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
|
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
|
@GOARCH=${ARCH} $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||||
@echo "Build complete: $(BINARY_PATH)"
|
@echo "Build complete: $(BINARY_PATH)"
|
||||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
|
@ -130,14 +130,17 @@ build: generate
|
||||||
build-launcher:
|
build-launcher:
|
||||||
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
@if [ ! -f web/backend/dist/index.html ]; then \
|
@GOARCH=${ARCH} $(MAKE) -C web build \
|
||||||
echo "Building frontend..."; \
|
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)" \
|
||||||
cd web/frontend && pnpm install && pnpm build:backend; \
|
WEB_GO='$(WEB_GO)' \
|
||||||
fi
|
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
|
||||||
@$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
|
LDFLAGS='$(LDFLAGS)'
|
||||||
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
||||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
||||||
|
|
||||||
|
build-launcher-frontend:
|
||||||
|
@$(MAKE) -C web build-frontend
|
||||||
|
|
||||||
## build-launcher-tui: Build the picoclaw-launcher TUI binary
|
## build-launcher-tui: Build the picoclaw-launcher TUI binary
|
||||||
build-launcher-tui:
|
build-launcher-tui:
|
||||||
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."
|
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."
|
||||||
|
|
@ -321,14 +324,13 @@ docker-clean:
|
||||||
|
|
||||||
|
|
||||||
## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window)
|
## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window)
|
||||||
build-macos-app:
|
build-macos-app:build-launcher
|
||||||
@echo "Building macOS .app bundle..."
|
@echo "Building macOS .app bundle..."
|
||||||
@if [ "$(UNAME_S)" != "Darwin" ]; then \
|
@if [ "$(UNAME_S)" != "Darwin" ]; then \
|
||||||
echo "Error: This target is only available on macOS"; \
|
echo "Error: This target is only available on macOS"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
@cd web && $(MAKE) build && cd ..
|
@./scripts/build-macos-app.sh $(PLATFORM)-$(ARCH)
|
||||||
@./scripts/build-macos-app.sh $(BINARY_NAME)-$(PLATFORM)-$(ARCH)
|
|
||||||
@echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app"
|
@echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app"
|
||||||
|
|
||||||
## help: Show this help message
|
## help: Show this help message
|
||||||
|
|
|
||||||
31
README.fr.md
31
README.fr.md
|
|
@ -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** !
|
||||||
|
|
@ -304,7 +306,25 @@ Pour la documentation détaillée du TUI, voir [docs.picoclaw.io](https://docs.p
|
||||||
|
|
||||||
Donnez une seconde vie à votre téléphone vieux de dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw.
|
Donnez une seconde vie à votre téléphone vieux de dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw.
|
||||||
|
|
||||||
**Option 1 : Termux (disponible maintenant)**
|
**Option 1 : Installation APK**
|
||||||
|
|
||||||
|
Aperçu :
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Téléchargez l'APK depuis [picoclaw.io](https://picoclaw.io/download/) et installez-le directement. Pas besoin de Termux !
|
||||||
|
|
||||||
|
**Option 2 : Termux**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Terminal Launcher (pour les environnements à ressources limitées)</b></summary>
|
||||||
|
|
||||||
1. Installez [Termux](https://github.com/termux/termux-app) (téléchargez depuis [GitHub Releases](https://github.com/termux/termux-app/releases), ou cherchez dans F-Droid / Google Play)
|
1. Installez [Termux](https://github.com/termux/termux-app) (téléchargez depuis [GitHub Releases](https://github.com/termux/termux-app/releases), ou cherchez dans F-Droid / Google Play)
|
||||||
2. Exécutez les commandes suivantes :
|
2. Exécutez les commandes suivantes :
|
||||||
|
|
@ -321,13 +341,6 @@ 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)**
|
|
||||||
|
|
||||||
Un APK Android autonome avec WebUI intégré est en développement. Restez à l'écoute !
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Terminal Launcher (pour les environnements à ressources limitées)</b></summary>
|
|
||||||
|
|
||||||
Pour les environnements minimaux où seul le binaire principal `picoclaw` est disponible (sans Launcher UI), vous pouvez tout configurer via la ligne de commande et un fichier de configuration JSON.
|
Pour les environnements minimaux où seul le binaire principal `picoclaw` est disponible (sans Launcher UI), vous pouvez tout configurer via la ligne de commande et un fichier de configuration JSON.
|
||||||
|
|
||||||
**1. Initialiser**
|
**1. Initialiser**
|
||||||
|
|
@ -462,6 +475,8 @@ Parlez à votre PicoClaw via plus de 17 plateformes de messagerie :
|
||||||
|
|
||||||
> Tous les channels basés sur webhook partagent un seul serveur HTTP Gateway (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP partagé.
|
> Tous les channels basés sur webhook partagent un seul serveur HTTP Gateway (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP partagé.
|
||||||
|
|
||||||
|
> La verbosité des logs est contrôlée par `gateway.log_level` (par défaut : `warn`). Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`. Peut aussi être défini via `PICOCLAW_LOG_LEVEL`. Voir [Configuration](docs/fr/configuration.md#niveau-de-log-du-gateway) pour plus de détails.
|
||||||
|
|
||||||
Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](docs/fr/chat-apps.md).
|
Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](docs/fr/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Outils
|
## 🔧 Outils
|
||||||
|
|
|
||||||
31
README.id.md
31
README.id.md
|
|
@ -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**!
|
||||||
|
|
@ -301,7 +303,25 @@ Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io
|
||||||
|
|
||||||
Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw.
|
Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw.
|
||||||
|
|
||||||
**Opsi 1: Termux (tersedia sekarang)**
|
**Opsi 1: Instal APK**
|
||||||
|
|
||||||
|
Pratinjau:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux!
|
||||||
|
|
||||||
|
**Opsi 2: Termux**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Terminal Launcher (untuk lingkungan dengan sumber daya terbatas)</b></summary>
|
||||||
|
|
||||||
1. Instal [Termux](https://github.com/termux/termux-app) (unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play)
|
1. Instal [Termux](https://github.com/termux/termux-app) (unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play)
|
||||||
2. Jalankan perintah berikut:
|
2. Jalankan perintah berikut:
|
||||||
|
|
@ -318,13 +338,6 @@ 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)**
|
|
||||||
|
|
||||||
APK Android mandiri dengan WebUI bawaan sedang dalam pengembangan. Pantau terus!
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Terminal Launcher (untuk lingkungan dengan sumber daya terbatas)</b></summary>
|
|
||||||
|
|
||||||
Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON.
|
Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON.
|
||||||
|
|
||||||
**1. Inisialisasi**
|
**1. Inisialisasi**
|
||||||
|
|
@ -458,6 +471,8 @@ Bicara dengan PicoClaw Anda melalui 17+ platform pesan:
|
||||||
|
|
||||||
> Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama.
|
> Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama.
|
||||||
|
|
||||||
|
> Verbositas log dikontrol oleh `gateway.log_level` (default: `warn`). Nilai yang didukung: `debug`, `info`, `warn`, `error`, `fatal`. Juga dapat diatur melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk detail.
|
||||||
|
|
||||||
Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md).
|
Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Tools
|
## 🔧 Tools
|
||||||
|
|
|
||||||
31
README.it.md
31
README.it.md
|
|
@ -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**!
|
||||||
|
|
@ -301,7 +303,25 @@ Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs.
|
||||||
|
|
||||||
Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw.
|
Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw.
|
||||||
|
|
||||||
**Opzione 1: Termux (disponibile ora)**
|
**Opzione 1: Installazione APK**
|
||||||
|
|
||||||
|
Anteprima:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux!
|
||||||
|
|
||||||
|
**Opzione 2: Termux**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Terminal Launcher (per ambienti con risorse limitate)</b></summary>
|
||||||
|
|
||||||
1. Installa [Termux](https://github.com/termux/termux-app) (scarica da [GitHub Releases](https://github.com/termux/termux-app/releases), o cerca su F-Droid / Google Play)
|
1. Installa [Termux](https://github.com/termux/termux-app) (scarica da [GitHub Releases](https://github.com/termux/termux-app/releases), o cerca su F-Droid / Google Play)
|
||||||
2. Esegui i seguenti comandi:
|
2. Esegui i seguenti comandi:
|
||||||
|
|
@ -318,13 +338,6 @@ 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)**
|
|
||||||
|
|
||||||
Un APK Android standalone con WebUI integrato è in sviluppo. Resta sintonizzato!
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Terminal Launcher (per ambienti con risorse limitate)</b></summary>
|
|
||||||
|
|
||||||
Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON.
|
Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON.
|
||||||
|
|
||||||
**1. Inizializza**
|
**1. Inizializza**
|
||||||
|
|
@ -458,6 +471,8 @@ Parla con il tuo PicoClaw attraverso 17+ piattaforme di messaggistica:
|
||||||
|
|
||||||
> Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso.
|
> Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso.
|
||||||
|
|
||||||
|
> La verbosità dei log è controllata da `gateway.log_level` (default: `warn`). Valori supportati: `debug`, `info`, `warn`, `error`, `fatal`. Può essere impostato anche tramite `PICOCLAW_LOG_LEVEL`. Vedi [Configurazione](docs/configuration.md#gateway-log-level) per i dettagli.
|
||||||
|
|
||||||
Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md).
|
Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Strumenti
|
## 🔧 Strumenti
|
||||||
|
|
|
||||||
31
README.ja.md
31
README.ja.md
|
|
@ -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 リリース!** システムトレイ UI(Windows & Linux)、サブエージェントステータス追跡(`spawn_status`)、実験的 Gateway ホットリロード、cron セキュリティゲート、セキュリティ修正 2 件。PicoClaw **25K ⭐** 達成!
|
2026-03-17 🚀 **v0.2.3 リリース!** システムトレイ UI(Windows & Linux)、サブエージェントステータス追跡(`spawn_status`)、実験的 Gateway ホットリロード、cron セキュリティゲート、セキュリティ修正 2 件。PicoClaw **25K ⭐** 達成!
|
||||||
|
|
@ -301,7 +303,25 @@ TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.i
|
||||||
|
|
||||||
10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。
|
10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。
|
||||||
|
|
||||||
**オプション 1: Termux(現在利用可能)**
|
**オプション 1: APK インストール**
|
||||||
|
|
||||||
|
プレビュー:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
[picoclaw.io](https://picoclaw.io/download/) から APK をダウンロードして直接インストール。Termux 不要!
|
||||||
|
|
||||||
|
**オプション 2: Termux**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Terminal Launcher(リソース制約環境向け)</b></summary>
|
||||||
|
|
||||||
1. [Termux](https://github.com/termux/termux-app) をインストール([GitHub Releases](https://github.com/termux/termux-app/releases) からダウンロード、または F-Droid / Google Play で検索)
|
1. [Termux](https://github.com/termux/termux-app) をインストール([GitHub Releases](https://github.com/termux/termux-app/releases) からダウンロード、または F-Droid / Google Play で検索)
|
||||||
2. 以下のコマンドを実行:
|
2. 以下のコマンドを実行:
|
||||||
|
|
@ -318,13 +338,6 @@ 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 インストール(近日公開)**
|
|
||||||
|
|
||||||
内蔵 WebUI を備えたスタンドアロン Android APK を開発中です。お楽しみに!
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Terminal Launcher(リソース制約環境向け)</b></summary>
|
|
||||||
|
|
||||||
`picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。
|
`picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。
|
||||||
|
|
||||||
**1. 初期化**
|
**1. 初期化**
|
||||||
|
|
@ -458,6 +471,8 @@ Provider の完全な設定詳細は [Provider とモデル](docs/ja/providers.m
|
||||||
|
|
||||||
> webhook ベースのすべての Channel は単一の Gateway HTTP サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)を共有します。Feishu は WebSocket/SDK モードを使用し、共有 HTTP サーバーを使用しません。
|
> webhook ベースのすべての Channel は単一の Gateway HTTP サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)を共有します。Feishu は WebSocket/SDK モードを使用し、共有 HTTP サーバーを使用しません。
|
||||||
|
|
||||||
|
> ログの詳細度は `gateway.log_level` で制御します(デフォルト:`warn`)。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。`PICOCLAW_LOG_LEVEL` 環境変数でも設定可能です。詳細は[設定ガイド](docs/ja/configuration.md#gateway-ログレベル)を参照してください。
|
||||||
|
|
||||||
Channel の詳細なセットアップ手順は [チャットアプリ設定](docs/ja/chat-apps.md) を参照してください。
|
Channel の詳細なセットアップ手順は [チャットアプリ設定](docs/ja/chat-apps.md) を参照してください。
|
||||||
|
|
||||||
## 🔧 ツール
|
## 🔧 ツール
|
||||||
|
|
|
||||||
34
README.md
34
README.md
|
|
@ -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**!
|
||||||
|
|
@ -301,7 +303,25 @@ For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io)
|
||||||
|
|
||||||
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw.
|
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw.
|
||||||
|
|
||||||
**Option 1: Termux (available now)**
|
**Option 1: APK Install**
|
||||||
|
|
||||||
|
Preview:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required!
|
||||||
|
|
||||||
|
**Option 2: Termux**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Terminal Launcher (for resource-constrained environments)</b></summary>
|
||||||
|
|
||||||
1. Install [Termux](https://github.com/termux/termux-app) (download from [GitHub Releases](https://github.com/termux/termux-app/releases), or search in F-Droid / Google Play)
|
1. Install [Termux](https://github.com/termux/termux-app) (download from [GitHub Releases](https://github.com/termux/termux-app/releases), or search in F-Droid / Google Play)
|
||||||
2. Run the following commands:
|
2. Run the following commands:
|
||||||
|
|
@ -318,13 +338,6 @@ 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)**
|
|
||||||
|
|
||||||
A standalone Android APK with built-in WebUI is in development. Stay tuned!
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Terminal Launcher (for resource-constrained environments)</b></summary>
|
|
||||||
|
|
||||||
For minimal environments where only the `picoclaw` core binary is available (no Launcher UI), you can configure everything via the command line and a JSON config file.
|
For minimal environments where only the `picoclaw` core binary is available (no Launcher UI), you can configure everything via the command line and a JSON config file.
|
||||||
|
|
||||||
**1. Initialize**
|
**1. Initialize**
|
||||||
|
|
@ -464,6 +477,8 @@ Talk to your PicoClaw through 17+ messaging platforms:
|
||||||
|
|
||||||
> All webhook-based channels share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu uses WebSocket/SDK mode and does not use the shared HTTP server.
|
> All webhook-based channels share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu uses WebSocket/SDK mode and does not use the shared HTTP server.
|
||||||
|
|
||||||
|
> Log verbosity is controlled by `gateway.log_level` (default: `warn`). Supported values: `debug`, `info`, `warn`, `error`, `fatal`. Can also be set via `PICOCLAW_LOG_LEVEL`. See [Configuration](docs/configuration.md#gateway-log-level) for details.
|
||||||
|
|
||||||
For detailed channel setup instructions, see [Chat Apps Configuration](docs/chat-apps.md).
|
For detailed channel setup instructions, see [Chat Apps Configuration](docs/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Tools
|
## 🔧 Tools
|
||||||
|
|
@ -574,6 +589,8 @@ PicoClaw supports scheduled reminders and recurring tasks through the `cron` too
|
||||||
* **Recurring tasks**: "Remind me every 2 hours" -> triggers every 2 hours
|
* **Recurring tasks**: "Remind me every 2 hours" -> triggers every 2 hours
|
||||||
* **Cron expressions**: "Remind me at 9am daily" -> uses cron expression
|
* **Cron expressions**: "Remind me at 9am daily" -> uses cron expression
|
||||||
|
|
||||||
|
See [docs/cron.md](docs/cron.md) for current schedule types, execution modes, command-job gates, and persistence details.
|
||||||
|
|
||||||
## 📚 Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
For detailed guides beyond this README:
|
For detailed guides beyond this README:
|
||||||
|
|
@ -583,6 +600,7 @@ For detailed guides beyond this README:
|
||||||
| [Docker & Quick Start](docs/docker.md) | Docker Compose setup, Launcher/Agent modes |
|
| [Docker & Quick Start](docs/docker.md) | Docker Compose setup, Launcher/Agent modes |
|
||||||
| [Chat Apps](docs/chat-apps.md) | All 17+ channel setup guides |
|
| [Chat Apps](docs/chat-apps.md) | All 17+ channel setup guides |
|
||||||
| [Configuration](docs/configuration.md) | Environment variables, workspace layout, security sandbox |
|
| [Configuration](docs/configuration.md) | Environment variables, workspace layout, security sandbox |
|
||||||
|
| [Scheduled Tasks and Cron Jobs](docs/cron.md) | Cron schedule types, deliver modes, command gates, job storage |
|
||||||
| [Providers & Models](docs/providers.md) | 30+ LLM providers, model routing, model_list configuration |
|
| [Providers & Models](docs/providers.md) | 30+ LLM providers, model routing, model_list configuration |
|
||||||
| [Spawn & Async Tasks](docs/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration |
|
| [Spawn & Async Tasks](docs/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration |
|
||||||
| [Hooks](docs/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks |
|
| [Hooks](docs/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks |
|
||||||
|
|
|
||||||
31
README.my.md
31
README.my.md
|
|
@ -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**!
|
||||||
|
|
@ -298,7 +300,25 @@ Untuk dokumentasi TUI terperinci, lihat [docs.picoclaw.io](https://docs.picoclaw
|
||||||
|
|
||||||
Berikan telefon lama anda kehidupan baru! Jadikannya Pembantu AI pintar dengan PicoClaw.
|
Berikan telefon lama anda kehidupan baru! Jadikannya Pembantu AI pintar dengan PicoClaw.
|
||||||
|
|
||||||
**Pilihan 1: Termux (tersedia sekarang)**
|
**Pilihan 1: Pasang APK**
|
||||||
|
|
||||||
|
Pratonton:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan!
|
||||||
|
|
||||||
|
**Pilihan 2: Termux**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Pelancar Terminal (untuk persekitaran terhad sumber)</b></summary>
|
||||||
|
|
||||||
1. Pasang [Termux](https://github.com/termux/termux-app) (muat turun dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play)
|
1. Pasang [Termux](https://github.com/termux/termux-app) (muat turun dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play)
|
||||||
2. Jalankan arahan berikut:
|
2. Jalankan arahan berikut:
|
||||||
|
|
@ -315,13 +335,6 @@ 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)**
|
|
||||||
|
|
||||||
APK Android bebas dengan WebUI terbina dalam sedang dalam pembangunan. Nantikan!
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Pelancar Terminal (untuk persekitaran terhad sumber)</b></summary>
|
|
||||||
|
|
||||||
Untuk persekitaran minimal di mana hanya binari teras `picoclaw` tersedia (tiada UI Pelancar), anda boleh mengkonfigurasi semua melalui baris arahan dan fail konfigurasi JSON.
|
Untuk persekitaran minimal di mana hanya binari teras `picoclaw` tersedia (tiada UI Pelancar), anda boleh mengkonfigurasi semua melalui baris arahan dan fail konfigurasi JSON.
|
||||||
|
|
||||||
**1. Mulakan**
|
**1. Mulakan**
|
||||||
|
|
@ -458,6 +471,8 @@ Bercakap dengan PicoClaw anda melalui 17+ platform pemesejan:
|
||||||
|
|
||||||
> Semua saluran berasaskan webhook berkongsi satu pelayan HTTP Gateway (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP yang dikongsi.
|
> Semua saluran berasaskan webhook berkongsi satu pelayan HTTP Gateway (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP yang dikongsi.
|
||||||
|
|
||||||
|
> Tahap perincian log dikawal oleh `gateway.log_level` (lalai: `warn`). Nilai yang disokong: `debug`, `info`, `warn`, `error`, `fatal`. Boleh juga ditetapkan melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk butiran.
|
||||||
|
|
||||||
Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](docs/my/chat-apps.md).
|
Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](docs/my/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Alat
|
## 🔧 Alat
|
||||||
|
|
|
||||||
|
|
@ -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**!
|
||||||
|
|
@ -301,7 +303,25 @@ Para documentação detalhada do TUI, veja [docs.picoclaw.io](https://docs.picoc
|
||||||
|
|
||||||
Dê uma segunda vida ao seu celular de uma década! Transforme-o em um Assistente de IA inteligente com o PicoClaw.
|
Dê uma segunda vida ao seu celular de uma década! Transforme-o em um Assistente de IA inteligente com o PicoClaw.
|
||||||
|
|
||||||
**Opção 1: Termux (disponível agora)**
|
**Opção 1: Instalação via APK**
|
||||||
|
|
||||||
|
Pré-visualização:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Baixe o APK de [picoclaw.io](https://picoclaw.io/download/) e instale diretamente. Sem necessidade de Termux!
|
||||||
|
|
||||||
|
**Opção 2: Termux**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Terminal Launcher (para ambientes com recursos limitados)</b></summary>
|
||||||
|
|
||||||
1. Instale o [Termux](https://github.com/termux/termux-app) (baixe nas [GitHub Releases](https://github.com/termux/termux-app/releases), ou pesquise no F-Droid / Google Play)
|
1. Instale o [Termux](https://github.com/termux/termux-app) (baixe nas [GitHub Releases](https://github.com/termux/termux-app/releases), ou pesquise no F-Droid / Google Play)
|
||||||
2. Execute os seguintes comandos:
|
2. Execute os seguintes comandos:
|
||||||
|
|
@ -318,13 +338,6 @@ 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)**
|
|
||||||
|
|
||||||
Um APK Android independente com WebUI integrado está em desenvolvimento. Fique ligado!
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Terminal Launcher (para ambientes com recursos limitados)</b></summary>
|
|
||||||
|
|
||||||
Para ambientes mínimos onde apenas o binário principal `picoclaw` está disponível (sem Launcher UI), você pode configurar tudo via linha de comando e um arquivo de configuração JSON.
|
Para ambientes mínimos onde apenas o binário principal `picoclaw` está disponível (sem Launcher UI), você pode configurar tudo via linha de comando e um arquivo de configuração JSON.
|
||||||
|
|
||||||
**1. Inicializar**
|
**1. Inicializar**
|
||||||
|
|
@ -458,6 +471,8 @@ Converse com seu PicoClaw por meio de mais de 17 plataformas de mensagens:
|
||||||
|
|
||||||
> Todos os channels baseados em webhook compartilham um único servidor HTTP do Gateway (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). O Feishu usa modo WebSocket/SDK e não utiliza o servidor HTTP compartilhado.
|
> Todos os channels baseados em webhook compartilham um único servidor HTTP do Gateway (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). O Feishu usa modo WebSocket/SDK e não utiliza o servidor HTTP compartilhado.
|
||||||
|
|
||||||
|
> A verbosidade dos logs é controlada por `gateway.log_level` (padrão: `warn`). Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`. Também pode ser definido via `PICOCLAW_LOG_LEVEL`. Veja [Configuração](docs/pt-br/configuration.md#nível-de-log-do-gateway) para detalhes.
|
||||||
|
|
||||||
Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](docs/pt-br/chat-apps.md).
|
Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](docs/pt-br/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Ferramentas
|
## 🔧 Ferramentas
|
||||||
|
|
|
||||||
31
README.vi.md
31
README.vi.md
|
|
@ -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**!
|
||||||
|
|
@ -301,7 +303,25 @@ Sử dụng menu TUI để: **1)** Cấu hình Provider -> **2)** Cấu hình Ch
|
||||||
|
|
||||||
Hãy cho chiếc điện thoại cũ của bạn một cuộc sống mới! Biến nó thành Trợ lý AI thông minh với PicoClaw.
|
Hãy cho chiếc điện thoại cũ của bạn một cuộc sống mới! Biến nó thành Trợ lý AI thông minh với PicoClaw.
|
||||||
|
|
||||||
**Tùy chọn 1: Termux (có sẵn ngay)**
|
**Tùy chọn 1: Cài đặt APK**
|
||||||
|
|
||||||
|
Xem trước:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Tải APK từ [picoclaw.io](https://picoclaw.io/download/) và cài đặt trực tiếp. Không cần Termux!
|
||||||
|
|
||||||
|
**Tùy chọn 2: Termux**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Terminal Launcher (cho môi trường hạn chế tài nguyên)</b></summary>
|
||||||
|
|
||||||
1. Cài đặt [Termux](https://github.com/termux/termux-app) (tải từ [GitHub Releases](https://github.com/termux/termux-app/releases), hoặc tìm kiếm trong F-Droid / Google Play)
|
1. Cài đặt [Termux](https://github.com/termux/termux-app) (tải từ [GitHub Releases](https://github.com/termux/termux-app/releases), hoặc tìm kiếm trong F-Droid / Google Play)
|
||||||
2. Chạy các lệnh sau:
|
2. Chạy các lệnh sau:
|
||||||
|
|
@ -318,13 +338,6 @@ 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)**
|
|
||||||
|
|
||||||
Một APK Android độc lập với WebUI tích hợp đang được phát triển. Hãy đón chờ!
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Terminal Launcher (cho môi trường hạn chế tài nguyên)</b></summary>
|
|
||||||
|
|
||||||
Đối với các môi trường tối giản chỉ có binary lõi `picoclaw` (không có Launcher UI), bạn có thể cấu hình mọi thứ qua dòng lệnh và tệp cấu hình JSON.
|
Đối với các môi trường tối giản chỉ có binary lõi `picoclaw` (không có Launcher UI), bạn có thể cấu hình mọi thứ qua dòng lệnh và tệp cấu hình JSON.
|
||||||
|
|
||||||
**1. Khởi tạo**
|
**1. Khởi tạo**
|
||||||
|
|
@ -458,6 +471,8 @@ Trò chuyện với PicoClaw của bạn qua 17+ nền tảng nhắn tin:
|
||||||
|
|
||||||
> Tất cả các Channel dựa trên webhook dùng chung một Gateway HTTP server (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Feishu sử dụng chế độ WebSocket/SDK và không dùng HTTP server chung.
|
> Tất cả các Channel dựa trên webhook dùng chung một Gateway HTTP server (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Feishu sử dụng chế độ WebSocket/SDK và không dùng HTTP server chung.
|
||||||
|
|
||||||
|
> Mức độ chi tiết log được kiểm soát bởi `gateway.log_level` (mặc định: `warn`). Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`. Cũng có thể đặt qua `PICOCLAW_LOG_LEVEL`. Xem [Cấu hình](docs/vi/configuration.md#mức-log-của-gateway) để biết thêm chi tiết.
|
||||||
|
|
||||||
Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](docs/vi/chat-apps.md).
|
Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](docs/vi/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Tools
|
## 🔧 Tools
|
||||||
|
|
|
||||||
31
README.zh.md
31
README.zh.md
|
|
@ -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、敏感数据过滤)、新增 Provider(AWS Bedrock、Azure、小米 MiMo),以及 35 项 Bug 修复。PicoClaw 已达 **26K ⭐**!
|
2026-03-25 🚀 **v0.2.4 发布!** Agent 架构全面重构(SubTurn、Hook、Steering、EventBus)、微信/企业微信深度集成、安全体系升级(.security.yml、敏感数据过滤)、新增 Provider(AWS Bedrock、Azure、小米 MiMo),以及 35 项 Bug 修复。PicoClaw 已达 **26K ⭐**!
|
||||||
|
|
||||||
2026-03-17 🚀 **v0.2.3 发布!** 系统托盘 UI(Windows & Linux)、子 Agent 状态查询 (`spawn_status`)、实验性 Gateway 热重载、Cron 安全门控,以及 2 项安全修复。PicoClaw 已达 **25K ⭐**!
|
2026-03-17 🚀 **v0.2.3 发布!** 系统托盘 UI(Windows & Linux)、子 Agent 状态查询 (`spawn_status`)、实验性 Gateway 热重载、Cron 安全门控,以及 2 项安全修复。PicoClaw 已达 **25K ⭐**!
|
||||||
|
|
@ -301,7 +303,25 @@ picoclaw-launcher-tui
|
||||||
|
|
||||||
让你十年前的旧手机焕发新生!将它变成你的 AI 助手。
|
让你十年前的旧手机焕发新生!将它变成你的 AI 助手。
|
||||||
|
|
||||||
**方式一:Termux(现已可用)**
|
**方式一:APK 安装**
|
||||||
|
|
||||||
|
预览:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
从 [picoclaw.io](https://picoclaw.io/download/) 下载 APK 并直接安装,无需 Termux!
|
||||||
|
|
||||||
|
**方式二:Termux**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Terminal Launcher(适用于资源受限环境)</b></summary>
|
||||||
|
|
||||||
1. 安装 [Termux](https://github.com/termux/termux-app)(可从 [GitHub Releases](https://github.com/termux/termux-app/releases) 下载,或在 F-Droid / Google Play 中搜索)
|
1. 安装 [Termux](https://github.com/termux/termux-app)(可从 [GitHub Releases](https://github.com/termux/termux-app/releases) 下载,或在 F-Droid / Google Play 中搜索)
|
||||||
2. 执行以下命令:
|
2. 执行以下命令:
|
||||||
|
|
@ -318,13 +338,6 @@ 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 安装(即将推出)**
|
|
||||||
|
|
||||||
内置 WebUI 的独立 Android APK 正在开发中,敬请期待!
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Terminal Launcher(适用于资源受限环境)</b></summary>
|
|
||||||
|
|
||||||
对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。
|
对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。
|
||||||
|
|
||||||
**1. 初始化**
|
**1. 初始化**
|
||||||
|
|
@ -458,6 +471,8 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模
|
||||||
|
|
||||||
> 所有基于 Webhook 的 Channel 共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。飞书使用 WebSocket/SDK 模式,不使用共享 HTTP 服务器。
|
> 所有基于 Webhook 的 Channel 共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。飞书使用 WebSocket/SDK 模式,不使用共享 HTTP 服务器。
|
||||||
|
|
||||||
|
> 日志详细程度通过 `gateway.log_level` 控制(默认:`warn`)。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。也可通过 `PICOCLAW_LOG_LEVEL` 环境变量设置。详见[配置指南](docs/zh/configuration.md#gateway-日志等级)。
|
||||||
|
|
||||||
详细 Channel 配置说明请参阅 [聊天应用配置](docs/zh/chat-apps.md)。
|
详细 Channel 配置说明请参阅 [聊天应用配置](docs/zh/chat-apps.md)。
|
||||||
|
|
||||||
## 🔧 Tools
|
## 🔧 Tools
|
||||||
|
|
|
||||||
BIN
assets/fui_log_page.jpg
Normal file
BIN
assets/fui_log_page.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/fui_main_page.jpg
Normal file
BIN
assets/fui_main_page.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/fui_setting_page.jpg
Normal file
BIN
assets/fui_setting_page.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
assets/fui_web_page.jpg
Normal file
BIN
assets/fui_web_page.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 365 KiB |
|
|
@ -7,9 +7,7 @@ package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -17,61 +15,30 @@ import (
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
"github.com/rivo/tview"
|
"github.com/rivo/tview"
|
||||||
)
|
|
||||||
|
|
||||||
const pidFileName = "gateway.pid"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
ppid "github.com/sipeed/picoclaw/pkg/pid"
|
||||||
|
)
|
||||||
|
|
||||||
type gatewayStatus struct {
|
type gatewayStatus struct {
|
||||||
running bool
|
running bool
|
||||||
pid int
|
pid int
|
||||||
|
version string
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPidPath() string {
|
func picoHome() string {
|
||||||
home, err := os.UserHomeDir()
|
return config.GetHome()
|
||||||
if err != nil {
|
|
||||||
home = "."
|
|
||||||
}
|
|
||||||
return filepath.Join(home, ".picoclaw", pidFileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isProcessRunning(pid int) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid))
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.Contains(string(output), strconv.Itoa(pid))
|
|
||||||
} else if runtime.GOOS == "darwin" {
|
|
||||||
cmd := exec.Command("ps", "aux")
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.Contains(string(output), fmt.Sprintf(" %d ", pid))
|
|
||||||
}
|
|
||||||
// Linux
|
|
||||||
_, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
|
|
||||||
return err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getGatewayStatus() gatewayStatus {
|
func getGatewayStatus() gatewayStatus {
|
||||||
pidPath := getPidPath()
|
data := ppid.ReadPidFileWithCheck(picoHome())
|
||||||
data, err := os.ReadFile(pidPath)
|
if data == nil {
|
||||||
if err != nil {
|
|
||||||
return gatewayStatus{running: false}
|
|
||||||
}
|
|
||||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
|
||||||
if err != nil {
|
|
||||||
return gatewayStatus{running: false}
|
|
||||||
}
|
|
||||||
if !isProcessRunning(pid) {
|
|
||||||
os.Remove(pidPath)
|
|
||||||
return gatewayStatus{running: false}
|
return gatewayStatus{running: false}
|
||||||
}
|
}
|
||||||
return gatewayStatus{
|
return gatewayStatus{
|
||||||
running: true,
|
running: true,
|
||||||
pid: pid,
|
pid: data.PID,
|
||||||
|
version: data.Version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,13 +48,12 @@ func startGateway() error {
|
||||||
return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
|
return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
|
||||||
}
|
}
|
||||||
|
|
||||||
pidPath := getPidPath()
|
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
|
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1")
|
cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1")
|
||||||
} else {
|
} else {
|
||||||
cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 & echo $! > "+pidPath)
|
cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 &")
|
||||||
}
|
}
|
||||||
|
|
||||||
err := cmd.Start()
|
err := cmd.Start()
|
||||||
|
|
@ -116,9 +82,8 @@ func startGateway() error {
|
||||||
if line == "" {
|
if line == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pid, err := strconv.Atoi(line)
|
_, err := strconv.Atoi(line)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0o600)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -141,21 +106,20 @@ func stopGateway() error {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run()
|
err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run()
|
||||||
} else {
|
} else {
|
||||||
err = exec.Command("kill", "-9", strconv.Itoa(status.pid)).Run()
|
err = exec.Command("kill", strconv.Itoa(status.pid)).Run()
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 多次尝试确认进程已停止
|
// Wait for process to stop (ReadPidFileWithCheck cleans up stale pid file)
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
if !isProcessRunning(status.pid) {
|
if !getGatewayStatus().running {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
time.Sleep(200 * time.Millisecond)
|
time.Sleep(200 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
os.Remove(getPidPath())
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,7 +181,11 @@ func (a *App) newGatewayPage() tview.Primitive {
|
||||||
updateStatus = func() {
|
updateStatus = func() {
|
||||||
status := getGatewayStatus()
|
status := getGatewayStatus()
|
||||||
if status.running {
|
if status.running {
|
||||||
statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d", status.pid))
|
versionInfo := ""
|
||||||
|
if status.version != "" {
|
||||||
|
versionInfo = fmt.Sprintf("\nVersion: %s", status.version)
|
||||||
|
}
|
||||||
|
statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d%s", status.pid, versionInfo))
|
||||||
buttons.SetItemText(0, " [gray]START[white] ", "")
|
buttons.SetItemText(0, " [gray]START[white] ", "")
|
||||||
buttons.SetItemText(1, " [red]STOP[white] ", "")
|
buttons.SetItemText(1, " [red]STOP[white] ", "")
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ func newAddCommand(storePath func() string) *cobra.Command {
|
||||||
message string
|
message string
|
||||||
every int64
|
every int64
|
||||||
cronExp string
|
cronExp string
|
||||||
deliver bool
|
|
||||||
channel string
|
channel string
|
||||||
to string
|
to string
|
||||||
)
|
)
|
||||||
|
|
@ -37,7 +36,7 @@ func newAddCommand(storePath func() string) *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
cs := cron.NewCronService(storePath(), nil)
|
cs := cron.NewCronService(storePath(), nil)
|
||||||
job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
|
job, err := cs.AddJob(name, schedule, message, channel, to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error adding job: %w", err)
|
return fmt.Errorf("error adding job: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +51,6 @@ func newAddCommand(storePath func() string) *cobra.Command {
|
||||||
cmd.Flags().StringVarP(&message, "message", "m", "", "Message for agent")
|
cmd.Flags().StringVarP(&message, "message", "m", "", "Message for agent")
|
||||||
cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds")
|
cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds")
|
||||||
cmd.Flags().StringVarP(&cronExp, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')")
|
cmd.Flags().StringVarP(&cronExp, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')")
|
||||||
cmd.Flags().BoolVarP(&deliver, "deliver", "d", false, "Deliver response to channel")
|
|
||||||
cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery")
|
cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery")
|
||||||
cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery")
|
cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ func TestNewAddSubcommand(t *testing.T) {
|
||||||
|
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("every"))
|
assert.NotNil(t, cmd.Flags().Lookup("every"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("cron"))
|
assert.NotNil(t, cmd.Flags().Lookup("cron"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("deliver"))
|
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("to"))
|
assert.NotNil(t, cmd.Flags().Lookup("to"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("channel"))
|
assert.NotNil(t, cmd.Flags().Lookup("channel"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,7 @@ const Logo = pkg.Logo
|
||||||
// GetPicoclawHome returns the picoclaw home directory.
|
// GetPicoclawHome returns the picoclaw home directory.
|
||||||
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||||
func GetPicoclawHome() string {
|
func GetPicoclawHome() string {
|
||||||
if home := os.Getenv(config.EnvHome); home != "" {
|
return config.GetHome()
|
||||||
return home
|
|
||||||
}
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
return filepath.Join(home, pkg.DefaultPicoClawHome)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetConfigPath() string {
|
func GetConfigPath() string {
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ func listAvailableModels(cfg *config.Config) {
|
||||||
if model.ModelName == defaultModel {
|
if model.ModelName == defaultModel {
|
||||||
marker = "> "
|
marker = "> "
|
||||||
}
|
}
|
||||||
if model.APIKey() == "" {
|
if !model.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
|
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
|
||||||
|
|
@ -92,7 +92,7 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er
|
||||||
// Validate that the model exists in model_list
|
// Validate that the model exists in model_list
|
||||||
modelFound := false
|
modelFound := false
|
||||||
for _, model := range cfg.ModelList {
|
for _, model := range cfg.ModelList {
|
||||||
if model.APIKey() != "" && model.ModelName == modelName {
|
if model.Enabled && model.ModelName == modelName {
|
||||||
modelFound = true
|
modelFound = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,11 +65,17 @@ func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []*config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}},
|
{
|
||||||
|
ModelName: "gpt-4",
|
||||||
|
Model: "openai/gpt-4",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ModelName: "claude-3",
|
ModelName: "claude-3",
|
||||||
Model: "anthropic/claude-3",
|
Model: "anthropic/claude-3",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +98,12 @@ func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []*config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}},
|
{
|
||||||
|
ModelName: "gpt-4",
|
||||||
|
Model: "openai/gpt-4",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,11 +135,17 @@ func TestListAvailableModels_WithModels(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []*config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}},
|
{
|
||||||
|
ModelName: "gpt-4",
|
||||||
|
Model: "openai/gpt-4",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ModelName: "claude-3",
|
ModelName: "claude-3",
|
||||||
Model: "anthropic/claude-3",
|
Model: "anthropic/claude-3",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
{ModelName: "no-key-model", Model: "openai/test"},
|
{ModelName: "no-key-model", Model: "openai/test"},
|
||||||
},
|
},
|
||||||
|
|
@ -158,11 +175,13 @@ func TestSetDefaultModel_ValidModel(t *testing.T) {
|
||||||
ModelName: "new-model",
|
ModelName: "new-model",
|
||||||
Model: "openai/new-model",
|
Model: "openai/new-model",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "old-model",
|
ModelName: "old-model",
|
||||||
Model: "openai/old-model",
|
Model: "openai/old-model",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -194,6 +213,7 @@ func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
||||||
ModelName: "existing-model",
|
ModelName: "existing-model",
|
||||||
Model: "openai/existing",
|
Model: "openai/existing",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -215,6 +235,7 @@ func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
|
||||||
ModelName: "existing-model",
|
ModelName: "existing-model",
|
||||||
Model: "openai/existing",
|
Model: "openai/existing",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
{ModelName: "no-key-model", Model: "openai/nokey"},
|
{ModelName: "no-key-model", Model: "openai/nokey"},
|
||||||
},
|
},
|
||||||
|
|
@ -238,6 +259,7 @@ func TestSetDefaultModel_SaveConfigError(t *testing.T) {
|
||||||
ModelName: "new-model",
|
ModelName: "new-model",
|
||||||
Model: "openai/new-model",
|
Model: "openai/new-model",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -283,6 +305,7 @@ func TestModelCommandExecution_Show(t *testing.T) {
|
||||||
ModelName: "test-model",
|
ModelName: "test-model",
|
||||||
Model: "openai/test",
|
Model: "openai/test",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -314,11 +337,13 @@ func TestModelCommandExecution_Set(t *testing.T) {
|
||||||
ModelName: "old-model",
|
ModelName: "old-model",
|
||||||
Model: "openai/old",
|
Model: "openai/old",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "new-model",
|
ModelName: "new-model",
|
||||||
Model: "openai/new",
|
Model: "openai/new",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -356,16 +381,19 @@ func TestListAvailableModels_MarkerLogic(t *testing.T) {
|
||||||
ModelName: "first-model",
|
ModelName: "first-model",
|
||||||
Model: "openai/first",
|
Model: "openai/first",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "middle-model",
|
ModelName: "middle-model",
|
||||||
Model: "openai/middle",
|
Model: "openai/middle",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "last-model",
|
ModelName: "last-model",
|
||||||
Model: "openai/last",
|
Model: "openai/last",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,11 @@ func onboard(encrypt bool) {
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println(" See README.md for 17+ supported providers.")
|
fmt.Println(" See README.md for 17+ supported providers.")
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
|
if encrypt {
|
||||||
|
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
|
||||||
|
} else {
|
||||||
|
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// promptPassphrase reads the encryption passphrase twice from the terminal
|
// promptPassphrase reads the encryption passphrase twice from the terminal
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/updater"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewPicoclawCommand() *cobra.Command {
|
func NewPicoclawCommand() *cobra.Command {
|
||||||
|
|
@ -45,6 +46,7 @@ func NewPicoclawCommand() *cobra.Command {
|
||||||
migrate.NewMigrateCommand(),
|
migrate.NewMigrateCommand(),
|
||||||
skills.NewSkillsCommand(),
|
skills.NewSkillsCommand(),
|
||||||
model.NewModelCommand(),
|
model.NewModelCommand(),
|
||||||
|
updater.NewUpdateCommand("picoclaw"),
|
||||||
version.NewVersionCommand(),
|
version.NewVersionCommand(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||||
"onboard",
|
"onboard",
|
||||||
"skills",
|
"skills",
|
||||||
"status",
|
"status",
|
||||||
|
"update",
|
||||||
"version",
|
"version",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,15 @@
|
||||||
"model": "deepseek/deepseek-chat",
|
"model": "deepseek/deepseek-chat",
|
||||||
"api_key": "sk-your-deepseek-key"
|
"api_key": "sk-your-deepseek-key"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"model_name": "venice-uncensored",
|
||||||
|
"model": "venice/venice-uncensored",
|
||||||
|
"api_key": "your-venice-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "lmstudio-local",
|
||||||
|
"model": "lmstudio/openai/gpt-oss-20b"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"model_name": "longcat",
|
"model_name": "longcat",
|
||||||
"model": "longcat/LongCat-Flash-Thinking",
|
"model": "longcat/LongCat-Flash-Thinking",
|
||||||
|
|
@ -412,7 +421,11 @@
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"read_file": {
|
"read_file": {
|
||||||
"enabled": true
|
"enabled": true,
|
||||||
|
"mode": "bytes"
|
||||||
|
},
|
||||||
|
"send_tts": {
|
||||||
|
"enabled": false
|
||||||
},
|
},
|
||||||
"spawn": {
|
"spawn": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ services:
|
||||||
picoclaw-gateway:
|
picoclaw-gateway:
|
||||||
image: docker.io/sipeed/picoclaw:latest
|
image: docker.io/sipeed/picoclaw:latest
|
||||||
container_name: picoclaw-gateway
|
container_name: picoclaw-gateway
|
||||||
restart: on-failure
|
restart: unless-stopped
|
||||||
profiles:
|
profiles:
|
||||||
- gateway
|
- gateway
|
||||||
# Uncomment to access host network; leave commented unless needed.
|
# Uncomment to access host network; leave commented unless needed.
|
||||||
|
|
@ -40,7 +40,7 @@ services:
|
||||||
picoclaw-launcher:
|
picoclaw-launcher:
|
||||||
image: docker.io/sipeed/picoclaw:launcher
|
image: docker.io/sipeed/picoclaw:launcher
|
||||||
container_name: picoclaw-launcher
|
container_name: picoclaw-launcher
|
||||||
restart: on-failure
|
restart: unless-stopped
|
||||||
profiles:
|
profiles:
|
||||||
- launcher
|
- launcher
|
||||||
environment:
|
environment:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -11,24 +11,35 @@ PicoClaw uses a schema versioning system for `config.json` to ensure smooth upgr
|
||||||
- **Changes**: Added `version` field to Config struct
|
- **Changes**: Added `version` field to Config struct
|
||||||
- **Migration**: No structural changes needed for existing configs
|
- **Migration**: No structural changes needed for existing configs
|
||||||
|
|
||||||
|
### Version 2
|
||||||
|
- **Introduction**: Model enable/disable support and channel config unification
|
||||||
|
- **Changes**:
|
||||||
|
- Added `enabled` field to `ModelConfig` — allows disabling individual model entries without removing them
|
||||||
|
- During V1→V2 migration, `enabled` is auto-inferred: models with API keys or the reserved `local-model` name are enabled; others default to disabled
|
||||||
|
- Migrated legacy channel fields: Discord `mention_only` → `group_trigger.mention_only`, OneBot `group_trigger_prefix` → `group_trigger.prefixes`
|
||||||
|
- V0 configs now migrate directly to CurrentVersion (V2) instead of going through V1
|
||||||
|
- `makeBackup()` now uses date-only suffix (e.g., `config.json.20260330.bak`) and also backs up `.security.yml`
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
### Automatic Migration
|
### Automatic Migration
|
||||||
When you load a config file:
|
When you load a config file:
|
||||||
1. The system first reads the `version` field from the JSON
|
1. The system first reads the `version` field from the JSON
|
||||||
2. Based on the detected version, it loads the appropriate config struct (`ConfigV0`, `ConfigV1`, etc.)
|
2. Based on the detected version, it loads the appropriate config struct (`configV0`, `configV1`, etc.)
|
||||||
3. If the loaded version is less than the latest, migrations are applied incrementally
|
3. If the loaded version is less than the latest, migrations are applied incrementally
|
||||||
4. The version number is updated automatically
|
4. Before saving, the system automatically creates a date-stamped backup of `config.json` and `.security.yml`
|
||||||
5. The migrated config is automatically saved back to disk
|
5. The version number is updated automatically
|
||||||
|
6. The migrated config is automatically saved back to disk
|
||||||
|
|
||||||
### Version Field
|
### Version Field
|
||||||
The `version` field in `config.json` indicates the schema version:
|
The `version` field in `config.json` indicates the schema version:
|
||||||
- `0` or missing: Legacy config (no version field)
|
- `0` or missing: Legacy config (no version field)
|
||||||
- `1`: Current version with versioning support
|
- `1`: Previous version (will be auto-migrated to V2 on load)
|
||||||
|
- `2`: Current version
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"agents": {...},
|
"agents": {...},
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
|
|
@ -54,25 +65,25 @@ type ConfigV2 struct {
|
||||||
### Step 2: Update Current Config Version
|
### Step 2: Update Current Config Version
|
||||||
|
|
||||||
```go
|
```go
|
||||||
const CurrentConfigVersion = 2 // Increment this
|
const CurrentVersion = 2 // Increment this
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 3: Add a Loader Function
|
### Step 3: Add a Loader Function
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// loadConfigV2 loads a version 2 config
|
// loadConfigV3 loads a version 3 config
|
||||||
func loadConfigV2(data []byte) (*Config, error) {
|
func loadConfigV3(data []byte) (*Config, error) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
// Parse to ConfigV2 struct
|
// Parse to ConfigV3 struct
|
||||||
var v2 ConfigV2
|
var v3 ConfigV3
|
||||||
if err := json.Unmarshal(data, &v2); err != nil {
|
if err := json.Unmarshal(data, &v3); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to current Config
|
// Convert to current Config
|
||||||
cfg.Version = v2.Version
|
cfg.Version = v3.Version
|
||||||
cfg.Agents = v2.Agents
|
cfg.Agents = v3.Agents
|
||||||
// ... map other fields
|
// ... map other fields
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
|
|
@ -82,29 +93,12 @@ func loadConfigV2(data []byte) (*Config, error) {
|
||||||
### Step 4: Add Migration Logic
|
### Step 4: Add Migration Logic
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// applyMigration applies a single migration step from fromVersion to toVersion
|
func (c *configV2) Migrate() (*Config, error) {
|
||||||
func applyMigration(cfg *Config, fromVersion, toVersion int) (*Config, error) {
|
// Apply V2→V3 structural changes here
|
||||||
switch toVersion {
|
migrated := &c.Config
|
||||||
case 1:
|
migrated.Version = 3
|
||||||
// Migration from version 0 to 1
|
// Apply structural changes
|
||||||
return &Config{
|
return migrated, nil
|
||||||
Version: 1,
|
|
||||||
Agents: cfg.Agents,
|
|
||||||
// ... copy all fields
|
|
||||||
}, nil
|
|
||||||
case 2:
|
|
||||||
// Migration from version 1 to 2
|
|
||||||
// Example: Move or rename fields
|
|
||||||
migrated := *cfg
|
|
||||||
migrated.Version = 2
|
|
||||||
// Apply structural changes
|
|
||||||
if cfg.SomeOldField != "" {
|
|
||||||
migrated.SomeNewField = cfg.SomeOldField
|
|
||||||
}
|
|
||||||
return &migrated, nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported migration target version: %d", toVersion)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -120,7 +114,9 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
case 1:
|
case 1:
|
||||||
cfg, err = loadConfigV1(data)
|
cfg, err = loadConfigV1(data)
|
||||||
case 2:
|
case 2:
|
||||||
cfg, err = loadConfigV2(data)
|
cfg, err = loadConfig(data)
|
||||||
|
case 3:
|
||||||
|
cfg, err = loadConfigV3(data)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
|
return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
|
||||||
}
|
}
|
||||||
|
|
@ -134,22 +130,22 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
Create a test in `config_migration_test.go`:
|
Create a test in `config_migration_test.go`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func TestMigrateV1ToV2(t *testing.T) {
|
func TestMigrateV2ToV3(t *testing.T) {
|
||||||
// Create a version 1 config
|
// Create a version 2 config
|
||||||
v1Config := Config{
|
v2Config := Config{
|
||||||
Version: 1,
|
Version: 2,
|
||||||
// ... set up test data
|
// ... set up test data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply migration
|
// Apply migration
|
||||||
migrated, err := applyMigration(&v1Config, 1, 2)
|
migrated, err := v2Config.Migrate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Migration failed: %v", err)
|
t.Fatalf("Migration failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify version is updated
|
// Verify version is updated
|
||||||
if migrated.Version != 2 {
|
if migrated.Version != 3 {
|
||||||
t.Errorf("Expected version 2, got %d", migrated.Version)
|
t.Errorf("Expected version 3, got %d", migrated.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify data is preserved/transformed correctly
|
// Verify data is preserved/transformed correctly
|
||||||
|
|
@ -164,58 +160,60 @@ func TestMigrateV1ToV2(t *testing.T) {
|
||||||
3. **No Data Loss**: Migrations should preserve all user settings
|
3. **No Data Loss**: Migrations should preserve all user settings
|
||||||
4. **Idempotent**: Running the same migration multiple times should be safe
|
4. **Idempotent**: Running the same migration multiple times should be safe
|
||||||
5. **Auto-Save**: Migrated configs are automatically saved to update the user's file
|
5. **Auto-Save**: Migrated configs are automatically saved to update the user's file
|
||||||
6. **Test Thoroughly**: Test with real user config files
|
6. **Auto-Backup**: Before saving, the system creates a date-stamped backup of `config.json` and `.security.yml`
|
||||||
7. **Update Defaults**: Keep `defaults.go` in sync with the latest schema
|
7. **Test Thoroughly**: Test with real user config files
|
||||||
|
8. **Update Defaults**: Keep `defaults.go` in sync with the latest schema
|
||||||
|
|
||||||
## Example Migration
|
## Example Migration
|
||||||
|
|
||||||
### Scenario: Adding a new field with default value
|
### Scenario: Adding a new field with default value
|
||||||
|
|
||||||
Old config (version 1):
|
Old config (version 2):
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"max_tokens": 32768
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Migration to version 2:
|
|
||||||
```go
|
|
||||||
case 2:
|
|
||||||
migrated := *cfg
|
|
||||||
migrated.Version = 2
|
|
||||||
|
|
||||||
// Add new field with default value if not set
|
|
||||||
if migrated.Agents.Defaults.NewFeatureEnabled == false {
|
|
||||||
// Use default value
|
|
||||||
}
|
|
||||||
|
|
||||||
return &migrated, nil
|
|
||||||
```
|
|
||||||
|
|
||||||
New config (version 2):
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"agents": {
|
"model_list": [
|
||||||
"defaults": {
|
{
|
||||||
"max_tokens": 32768,
|
"model_name": "gpt-5.4",
|
||||||
"new_feature_enabled": false
|
"model": "openai/gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Migration to version 3:
|
||||||
|
```go
|
||||||
|
func (c *configV2) Migrate() (*Config, error) {
|
||||||
|
migrated := &c.Config
|
||||||
|
migrated.Version = 3
|
||||||
|
|
||||||
|
// Add new field with default value if not set
|
||||||
|
// ...
|
||||||
|
|
||||||
|
return migrated, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
New config (version 3):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
|
"new_option": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Config Not Upgrading
|
### Config Not Upgrading
|
||||||
- Check that `CurrentConfigVersion` is incremented
|
- Check that `CurrentVersion` is incremented
|
||||||
- Verify migration logic in `applyMigration()` handles the target version
|
- Verify migration logic handles the target version
|
||||||
- Ensure `migrateConfig()` is called in `LoadConfig()`
|
- Ensure `Migrate()` is called in `LoadConfig()`
|
||||||
|
|
||||||
### Migration Errors
|
### Migration Errors
|
||||||
- Check error messages for specific migration failures
|
- Check error messages for specific migration failures
|
||||||
|
|
@ -227,4 +225,5 @@ New config (version 2):
|
||||||
- Ensure all fields are copied during migration
|
- Ensure all fields are copied during migration
|
||||||
- Check that the migration doesn't overwrite values with defaults unnecessarily
|
- Check that the migration doesn't overwrite values with defaults unnecessarily
|
||||||
- Review the conversion logic in the loader functions
|
- Review the conversion logic in the loader functions
|
||||||
|
- Check the auto-backup files (e.g., `config.json.20260330.bak`) to recover original data
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
|
|
||||||
Config file: `~/.picoclaw/config.json`
|
Config file: `~/.picoclaw/config.json`
|
||||||
|
|
||||||
|
> **Security Configuration:** For storing API keys, tokens, and other sensitive data, see the [Security Configuration Guide](security_configuration.md).
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
You can override default paths using environment variables. This is useful for portable installations, containerized deployments, or running picoclaw as a system service. These variables are independent and control different paths.
|
You can override default paths using environment variables. This is useful for portable installations, containerized deployments, or running picoclaw as a system service. These variables are independent and control different paths.
|
||||||
|
|
@ -38,12 +40,12 @@ PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gat
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"log_level": "fatal"
|
"log_level": "warn"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
When omitted, the default is `fatal`. Supported values: `debug`, `info`, `warn`, `error`, `fatal`.
|
When omitted, the default is `warn`. Supported values: `debug`, `info`, `warn`, `error`, `fatal`.
|
||||||
|
|
||||||
You can also override this with the environment variable `PICOCLAW_LOG_LEVEL`.
|
You can also override this with the environment variable `PICOCLAW_LOG_LEVEL`.
|
||||||
|
|
||||||
|
|
@ -67,6 +69,18 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
|
||||||
|
|
||||||
> **Note:** Changes to `AGENT.md`, `SOUL.md`, `USER.md` and `memory/MEMORY.md` are automatically detected at runtime via file modification time (mtime) tracking. You do **not** need to restart the gateway after editing these files — the agent picks up the new content on the next request.
|
> **Note:** Changes to `AGENT.md`, `SOUL.md`, `USER.md` and `memory/MEMORY.md` are automatically detected at runtime via file modification time (mtime) tracking. You do **not** need to restart the gateway after editing these files — the agent picks up the new content on the next request.
|
||||||
|
|
||||||
|
### Web launcher dashboard
|
||||||
|
|
||||||
|
**picoclaw-launcher** serves a browser UI that requires sign-in first. By default, the **dashboard token** and **session signing key** are **generated in memory on each start** (a new random token after every restart). Set **`PICOCLAW_LAUNCHER_TOKEN`** to pin a fixed token for that process (startup logs do not print the secret when this env var is used).
|
||||||
|
|
||||||
|
**Where to read the token**: In **console mode** (`-console`), it is printed at startup. In **tray / GUI mode**, use the tray action **Copy dashboard token**, and check **`$PICOCLAW_HOME/logs/launcher.log`** (typically `~/.picoclaw/logs/launcher.log` if `PICOCLAW_HOME` is unset) for the random token logged on startup. The login page shows hints that match how the launcher is running (including the absolute log path); **responses do not include the token itself**.
|
||||||
|
|
||||||
|
- **Config file**: Same directory as `config.json` (or the file pointed to by `PICOCLAW_CONFIG`). The launcher-specific file is `launcher-config.json`.
|
||||||
|
- **Sign-in and links**: Enter the token on the login page, or open with `?token=` when the browser is launched automatically. All responses include **`Referrer-Policy: no-referrer`** to reduce leakage of `token` via the `Referer` header.
|
||||||
|
- **Sign-out**: Use **`POST /api/auth/logout`** with **`Content-Type: application/json`** (body may be `{}`). Do not rely on a GET URL for logout (CSRF-safe pattern).
|
||||||
|
- **Brute-force**: **`POST /api/auth/login`** is **rate-limited per client IP per minute** (HTTP 429 when exceeded).
|
||||||
|
- **Session lifetime**: The HttpOnly session cookie lasts about **7 days** by default; sign in again with the token after it expires.
|
||||||
|
|
||||||
### Skill Sources
|
### Skill Sources
|
||||||
|
|
||||||
By default, skills are loaded from:
|
By default, skills are loaded from:
|
||||||
|
|
@ -287,6 +301,66 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous
|
||||||
| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace |
|
| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace |
|
||||||
| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace |
|
| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace |
|
||||||
|
|
||||||
|
### Read File Mode
|
||||||
|
|
||||||
|
`read_file` has two mutually exclusive implementations selected by config. PicoClaw registers exactly one of them at startup:
|
||||||
|
|
||||||
|
| Config Key | Type | Default | Description |
|
||||||
|
|------------|------|---------|-------------|
|
||||||
|
| `tools.read_file.enabled` | bool | `true` | Enables the `read_file` tool |
|
||||||
|
| `tools.read_file.mode` | string | `bytes` | Selects the `read_file` implementation: `bytes` or `lines` |
|
||||||
|
| `tools.read_file.max_read_file_size` | int | `65536` | Maximum bytes returned by `read_file` |
|
||||||
|
|
||||||
|
#### Mode: `bytes`
|
||||||
|
|
||||||
|
Optimized for arbitrary files and binary-safe pagination.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
* `path` (required): File path
|
||||||
|
* `offset` (optional): Starting byte offset, default `0`
|
||||||
|
* `length` (optional): Maximum number of bytes to read, default `max_read_file_size`
|
||||||
|
|
||||||
|
Use `bytes` when:
|
||||||
|
|
||||||
|
* You may read binary files
|
||||||
|
* You want deterministic byte-range pagination
|
||||||
|
|
||||||
|
#### Mode: `lines`
|
||||||
|
|
||||||
|
Text-oriented behavior, optimized for source files, markdown, logs, and configs. The tool reads sequentially by line and stops when the configured byte budget is reached.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
* `path` (required): File path
|
||||||
|
* `start_line` (optional): Starting line number, 1-indexed and inclusive, default `1`
|
||||||
|
* `max_lines` (optional): Maximum number of lines to read, default = all remaining lines until EOF or byte budget
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
|
||||||
|
* Binary-looking files are rejected with guidance to switch `read_file` to `mode = bytes`
|
||||||
|
* Extremely long single lines are truncated rather than skipped
|
||||||
|
|
||||||
|
Use `mode = lines` when:
|
||||||
|
|
||||||
|
* The agent mostly reads text files
|
||||||
|
* You want line-based pagination in prompts and tool calls
|
||||||
|
* You want cleaner chunks for code review, logs, and documentation
|
||||||
|
|
||||||
|
#### Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"read_file": {
|
||||||
|
"enabled": true,
|
||||||
|
"mode": "lines",
|
||||||
|
"max_read_file_size": 65536
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Exec Security
|
### Exec Security
|
||||||
|
|
||||||
| Config Key | Type | Default | Description |
|
| Config Key | Type | Default | Description |
|
||||||
|
|
@ -467,8 +541,9 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
|
|
||||||
- **Different agents, different providers**: Each agent can use its own LLM provider
|
- **Different agents, different providers**: Each agent can use its own LLM provider
|
||||||
- **Model fallbacks**: Configure primary and fallback models for resilience
|
- **Model fallbacks**: Configure primary and fallback models for resilience
|
||||||
- **Load balancing**: Distribute requests across multiple endpoints
|
- **Load balancing**: Distribute requests across multiple endpoints or keys
|
||||||
- **Centralized configuration**: Manage all providers in one place
|
- **Centralized configuration**: Manage all providers in one place
|
||||||
|
- **Model enable/disable**: Use the `enabled` field to temporarily disable a model without removing its configuration
|
||||||
|
|
||||||
#### 🔒 Security Configuration (Recommended)
|
#### 🔒 Security Configuration (Recommended)
|
||||||
|
|
||||||
|
|
@ -548,6 +623,7 @@ For complete documentation, see [`security_configuration.md`](security_configura
|
||||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
||||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
||||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
||||||
|
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) |
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
||||||
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
|
|
@ -569,22 +645,22 @@ For complete documentation, see [`security_configuration.md`](security_configura
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key"
|
"api_keys": ["sk-your-api-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_keys": ["sk-your-openai-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key"
|
"api_keys": ["sk-ant-your-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-zhipu-key"
|
"api_keys": ["your-zhipu-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
@ -595,7 +671,9 @@ For complete documentation, see [`security_configuration.md`](security_configura
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Security Note**: You can remove `api_key` fields from your config and store them in `.security.yml` instead. See [Security Configuration](#-security-configuration-recommended) above for details.
|
> **Security Note**: You can remove `api_keys` fields from your config and store them in `.security.yml` instead. See [Security Configuration](#-security-configuration-recommended) above for details.
|
||||||
|
>
|
||||||
|
> **Note**: The `enabled` field can be set to `false` to disable a model entry without removing it. When omitted, it defaults to `true` during migration for models that have API keys.
|
||||||
|
|
||||||
#### Vendor-Specific Examples
|
#### Vendor-Specific Examples
|
||||||
|
|
||||||
|
|
@ -672,7 +750,7 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
|
||||||
{
|
{
|
||||||
"model_name": "claude-opus-4-6",
|
"model_name": "claude-opus-4-6",
|
||||||
"model": "anthropic-messages/claude-opus-4-6",
|
"model": "anthropic-messages/claude-opus-4-6",
|
||||||
"api_key": "sk-ant-your-key",
|
"api_keys": ["sk-ant-your-key"],
|
||||||
"api_base": "https://api.anthropic.com"
|
"api_base": "https://api.anthropic.com"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -693,6 +771,21 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>LM Studio (local)</b></summary>
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "lmstudio-local",
|
||||||
|
"model": "lmstudio/openai/gpt-oss-20b"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.<br/>
|
||||||
|
PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio/` prefix before sending requests, so `lmstudio/openai/gpt-oss-20b` sends `openai/gpt-oss-20b` to the LM Studio server.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Custom Proxy / LiteLLM</b></summary>
|
<summary><b>Custom Proxy / LiteLLM</b></summary>
|
||||||
|
|
||||||
|
|
@ -747,13 +840,13 @@ model_list:
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_keys": ["sk-key1"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_keys": ["sk-key2"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -761,7 +854,7 @@ model_list:
|
||||||
|
|
||||||
#### Migration from Legacy `providers` Config
|
#### Migration from Legacy `providers` Config
|
||||||
|
|
||||||
The old `providers` configuration is **deprecated** but still supported for backward compatibility. See [docs/migration/model-list-migration.md](../migration/model-list-migration.md) for the full guide.
|
The old `providers` configuration is **deprecated** and has been removed in V2. Existing V0/V1 configs are auto-migrated. See [docs/migration/model-list-migration.md](../migration/model-list-migration.md) for the full guide.
|
||||||
|
|
||||||
### Provider Architecture
|
### Provider Architecture
|
||||||
|
|
||||||
|
|
@ -771,7 +864,7 @@ PicoClaw routes providers by protocol family:
|
||||||
- **Anthropic**: Claude-native API behavior.
|
- **Anthropic**: Claude-native API behavior.
|
||||||
- **Codex/OAuth**: OpenAI OAuth/token authentication route.
|
- **Codex/OAuth**: OpenAI OAuth/token authentication route.
|
||||||
|
|
||||||
This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`).
|
This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_keys`).
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Zhipu (legacy providers format)</b></summary>
|
<summary><b>Zhipu (legacy providers format)</b></summary>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Credential Encryption
|
# Credential Encryption
|
||||||
|
|
||||||
PicoClaw supports encrypting `api_key` values in `model_list` configuration entries.
|
PicoClaw supports encrypting `api_key`/`api_keys` values in `model_list` configuration entries.
|
||||||
Encrypted keys are stored as `enc://<base64>` strings and decrypted automatically at startup.
|
Encrypted keys are stored as `enc://<base64>` strings and decrypted automatically at startup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -42,6 +42,8 @@ enc://AAAA...base64...
|
||||||
|
|
||||||
## Supported `api_key` Formats
|
## Supported `api_key` Formats
|
||||||
|
|
||||||
|
The same formats apply to both `api_key` (singular) and individual elements in the `api_keys` (array) field:
|
||||||
|
|
||||||
| Format | Example | Behaviour |
|
| Format | Example | Behaviour |
|
||||||
|--------|---------|-----------|
|
|--------|---------|-----------|
|
||||||
| Plaintext | `sk-abc123` | Used as-is |
|
| Plaintext | `sk-abc123` | Used as-is |
|
||||||
|
|
|
||||||
125
docs/cron.md
Normal file
125
docs/cron.md
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# Scheduled Tasks and Cron Jobs
|
||||||
|
|
||||||
|
> Back to [README](../README.md)
|
||||||
|
|
||||||
|
PicoClaw stores scheduled jobs in the current workspace and can run them either as reminders, full agent turns, or shell commands.
|
||||||
|
|
||||||
|
## Schedule Types
|
||||||
|
|
||||||
|
PicoClaw currently uses three schedule forms in the cron tool:
|
||||||
|
|
||||||
|
- `at_seconds`: one-time job, relative to now. After it runs, the job is removed from the store.
|
||||||
|
- `every_seconds`: recurring interval, in seconds.
|
||||||
|
- `cron_expr`: recurring cron expression such as `0 9 * * *`.
|
||||||
|
|
||||||
|
The CLI command `picoclaw cron add` currently supports recurring jobs only:
|
||||||
|
|
||||||
|
- `--every <seconds>`
|
||||||
|
- `--cron '<expr>'`
|
||||||
|
|
||||||
|
There is no CLI flag for a one-time `at` job today.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw cron add --name "Daily summary" --message "Summarize today's logs" --cron "0 18 * * *"
|
||||||
|
picoclaw cron add --name "Ping" --message "heartbeat" --every 300 --deliver
|
||||||
|
```
|
||||||
|
|
||||||
|
## Execution Modes
|
||||||
|
|
||||||
|
Jobs are stored with a message payload and can execute in three stable user-facing modes:
|
||||||
|
|
||||||
|
### `deliver: false`
|
||||||
|
|
||||||
|
This is the default for the cron tool.
|
||||||
|
|
||||||
|
When the job fires, PicoClaw sends the saved message back through the agent loop as a new agent turn. Use this for scheduled work that may need reasoning, tools, or a generated reply.
|
||||||
|
|
||||||
|
### `deliver: true`
|
||||||
|
|
||||||
|
When the job fires, PicoClaw publishes the saved message directly to the target channel and recipient without agent processing.
|
||||||
|
|
||||||
|
The CLI `picoclaw cron add --deliver` flag uses this mode.
|
||||||
|
|
||||||
|
### `command`
|
||||||
|
|
||||||
|
When a cron-tool job includes `command`, PicoClaw runs that shell command through the `exec` tool and publishes the command output back to the channel.
|
||||||
|
|
||||||
|
For command jobs, `deliver` is forced to `false` when the job is created. The saved `message` becomes descriptive text only; the scheduled action is the shell command.
|
||||||
|
|
||||||
|
The current CLI `picoclaw cron add` command does not expose a `command` flag.
|
||||||
|
|
||||||
|
## Config and Security Gates
|
||||||
|
|
||||||
|
### `tools.cron`
|
||||||
|
|
||||||
|
`tools.cron.enabled` controls whether the agent-facing `cron` tool is registered. Default: `true`.
|
||||||
|
|
||||||
|
If you disable `tools.cron`, users can no longer create or manage jobs through the agent tool. The gateway still starts `CronService`, but it does not install the job execution callback. As a result, due jobs do not actually run; one-time jobs may be deleted and recurring jobs may be rescheduled without executing their payload. The CLI still uses the same job store.
|
||||||
|
|
||||||
|
`tools.cron.exec_timeout_minutes` sets the timeout used for scheduled command execution. Default: `5`. Set `0` for no timeout.
|
||||||
|
|
||||||
|
### `tools.exec`
|
||||||
|
|
||||||
|
Scheduled command jobs depend on `tools.exec.enabled`. Default: `true`.
|
||||||
|
|
||||||
|
If `tools.exec.enabled` is `false`:
|
||||||
|
|
||||||
|
- new command jobs are rejected by the cron tool
|
||||||
|
- existing command jobs publish a `command execution is disabled` error when they fire
|
||||||
|
|
||||||
|
`tools.exec.allow_remote` is still enforced by the exec tool, but cron command scheduling already requires an internal channel when the job is created. In practice, reminder jobs can be scheduled from remote channels, while scheduled command jobs are limited to internal channels.
|
||||||
|
|
||||||
|
### `allow_command`
|
||||||
|
|
||||||
|
`tools.cron.allow_command` defaults to `true`.
|
||||||
|
|
||||||
|
This is not a hard disable switch. If you set `allow_command` to `false`, PicoClaw still allows a command job when the caller explicitly passes `command_confirm: true`.
|
||||||
|
|
||||||
|
Command jobs also require an internal channel. Non-command reminders do not have that restriction.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"cron": {
|
||||||
|
"enabled": true,
|
||||||
|
"exec_timeout_minutes": 5,
|
||||||
|
"allow_command": true
|
||||||
|
},
|
||||||
|
"exec": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Persistence and Location
|
||||||
|
|
||||||
|
Cron jobs are stored in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<workspace>/cron/jobs.json
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, the workspace is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.picoclaw/workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
If `PICOCLAW_HOME` is set, the default workspace becomes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$PICOCLAW_HOME/workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Both the gateway and `picoclaw cron` CLI subcommands use the same `cron/jobs.json` file.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- one-time `at_seconds` jobs are deleted after they run
|
||||||
|
- recurring jobs stay in the store until removed
|
||||||
|
- disabled jobs stay in the store and still appear in `picoclaw cron list`
|
||||||
|
|
@ -48,7 +48,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||||
Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically.
|
Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> The web console does not yet support authentication. Avoid exposing it to the public internet.
|
> The web console uses a dashboard token (in-memory per run unless `PICOCLAW_LAUNCHER_TOKEN` is set). **Do not** expose the launcher to untrusted networks or the public internet. See [Web launcher dashboard](configuration.md#web-launcher-dashboard) in the Configuration Guide.
|
||||||
|
|
||||||
### Agent Mode (One-shot)
|
### Agent Mode (One-shot)
|
||||||
|
|
||||||
|
|
@ -95,19 +95,19 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key",
|
"api_keys": ["sk-your-api-key"],
|
||||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "your-api-key",
|
"api_keys": ["your-api-key"],
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "your-anthropic-key"
|
"api_keys": ["your-anthropic-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Niveau de Log du Gateway
|
||||||
|
|
||||||
|
`gateway.log_level` contrôle la verbosité des logs du Gateway, configurable dans `config.json` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gateway": {
|
||||||
|
"log_level": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
La valeur par défaut est `warn`. Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`.
|
||||||
|
|
||||||
|
Peut également être surchargé via la variable d'environnement : `PICOCLAW_LOG_LEVEL=info`
|
||||||
|
|
||||||
### Structure du Workspace
|
### Structure du Workspace
|
||||||
|
|
||||||
PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) :
|
PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) :
|
||||||
|
|
@ -318,15 +334,15 @@ Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectu
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" },
|
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] },
|
||||||
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" }
|
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Migration depuis l'ancienne config `providers`
|
#### Migration depuis l'ancienne config `providers`
|
||||||
|
|
||||||
L'ancienne configuration `providers` est **dépréciée** mais toujours supportée. Voir [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
|
L'ancienne configuration `providers` est **dépréciée** et a été supprimée dans V2. Les configs V0/V1 existantes sont auto-migrées. Voir [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
|
||||||
|
|
||||||
### Architecture des Providers
|
### Architecture des Providers
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,19 +92,19 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key",
|
"api_keys": ["sk-your-api-key"],
|
||||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "your-api-key",
|
"api_keys": ["your-api-key"],
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "your-anthropic-key"
|
"api_keys": ["your-anthropic-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
|
||||||
|
|
@ -73,22 +73,22 @@ Cette conception permet également le **support multi-agents** avec une sélecti
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key"
|
"api_keys": ["sk-your-api-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_keys": ["sk-your-openai-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key"
|
"api_keys": ["sk-ant-your-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-zhipu-key"
|
"api_keys": ["your-zhipu-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
@ -99,6 +99,24 @@ Cette conception permet également le **support multi-agents** avec une sélecti
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Champs d'entrée `model_list`
|
||||||
|
|
||||||
|
| Champ | Type | Requis | Description |
|
||||||
|
|-------|------|--------|-------------|
|
||||||
|
| `model_name` | string | Oui | Nom unique pour référencer ce modèle dans la config agent |
|
||||||
|
| `model` | string | Oui | Identifiant fournisseur/modèle (ex : `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
|
||||||
|
| `api_keys` | string[] | Oui* | Clé(s) API pour l'authentification. Plusieurs clés permettent la rotation par requête. Non requis pour les fournisseurs locaux (Ollama, LM Studio, VLLM) |
|
||||||
|
| `api_base` | string | Non | Remplace l'URL de base API par défaut |
|
||||||
|
| `proxy` | string | Non | URL du proxy HTTP pour cette entrée de modèle |
|
||||||
|
| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers OpenAI-compatible, Anthropic et Azure) |
|
||||||
|
| `request_timeout` | int | Non | Délai d'expiration de la requête en secondes (la valeur par défaut varie selon le provider) |
|
||||||
|
| `max_tokens_field` | string | Non | Remplace le nom du champ max tokens dans le corps de la requête (ex : `max_completion_tokens` pour les modèles o1) |
|
||||||
|
| `thinking_level` | string | Non | Niveau de pensée étendue : `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` |
|
||||||
|
| `extra_body` | object | Non | Champs supplémentaires à injecter dans chaque corps de requête |
|
||||||
|
| `rpm` | int | Non | Limite de requêtes par minute |
|
||||||
|
| `fallbacks` | string[] | Non | Noms des modèles de secours pour le basculement automatique |
|
||||||
|
| `enabled` | bool | Non | Activer ou désactiver cette entrée de modèle (par défaut : `true`) |
|
||||||
|
|
||||||
#### Exemples par Vendor
|
#### Exemples par Vendor
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
|
|
@ -107,7 +125,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -117,7 +135,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -127,7 +145,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-key"
|
"api_keys": ["your-key"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -137,7 +155,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti
|
||||||
{
|
{
|
||||||
"model_name": "deepseek-chat",
|
"model_name": "deepseek-chat",
|
||||||
"model": "deepseek/deepseek-chat",
|
"model": "deepseek/deepseek-chat",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -147,7 +165,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key"
|
"api_keys": ["sk-ant-your-key"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -161,7 +179,7 @@ Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne p
|
||||||
{
|
{
|
||||||
"model_name": "claude-opus-4-6",
|
"model_name": "claude-opus-4-6",
|
||||||
"model": "anthropic-messages/claude-opus-4-6",
|
"model": "anthropic-messages/claude-opus-4-6",
|
||||||
"api_key": "sk-ant-your-key",
|
"api_keys": ["sk-ant-your-key"],
|
||||||
"api_base": "https://api.anthropic.com"
|
"api_base": "https://api.anthropic.com"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -189,7 +207,8 @@ Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne p
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-...",
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -201,7 +220,7 @@ Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne p
|
||||||
"model_name": "lite-gpt4",
|
"model_name": "lite-gpt4",
|
||||||
"model": "litellm/lite-gpt4",
|
"model": "litellm/lite-gpt4",
|
||||||
"api_base": "http://localhost:4000/v1",
|
"api_base": "http://localhost:4000/v1",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -218,13 +237,13 @@ Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectu
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_keys": ["sk-key1"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_keys": ["sk-key2"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -232,7 +251,7 @@ Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectu
|
||||||
|
|
||||||
#### Migration depuis l'Ancienne Configuration `providers`
|
#### Migration depuis l'Ancienne Configuration `providers`
|
||||||
|
|
||||||
L'ancienne configuration `providers` est **dépréciée** mais toujours prise en charge pour la compatibilité ascendante.
|
L'ancienne configuration `providers` est **dépréciée** et a été supprimée dans V2. Les configs V0/V1 existantes sont auto-migrées.
|
||||||
|
|
||||||
**Ancienne configuration (dépréciée) :**
|
**Ancienne configuration (dépréciée) :**
|
||||||
|
|
||||||
|
|
@ -257,11 +276,12 @@ L'ancienne configuration `providers` est **dépréciée** mais toujours prise en
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"version": 2,
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-key"
|
"api_keys": ["your-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
# ⚙️ Guida alla Configurazione
|
|
||||||
|
|
||||||
> Torna al [README](../../README.md)
|
|
||||||
|
|
||||||
## ⚙️ Configurazione
|
|
||||||
|
|
||||||
File di configurazione: `~/.picoclaw/config.json`
|
|
||||||
|
|
||||||
### Variabili d'Ambiente
|
|
||||||
|
|
||||||
Puoi sovrascrivere i percorsi predefiniti usando variabili d'ambiente. Questo è utile per installazioni portatili, distribuzioni containerizzate, o per eseguire picoclaw come servizio di sistema. Queste variabili sono indipendenti e controllano percorsi diversi.
|
|
||||||
|
|
||||||
| Variabile | Descrizione | Percorso Predefinito |
|
|
||||||
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
|
||||||
| `PICOCLAW_CONFIG` | Sovrascrive il percorso al file di configurazione. Indica direttamente a picoclaw quale `config.json` caricare, ignorando tutte le altre posizioni. | `~/.picoclaw/config.json` |
|
|
||||||
| `PICOCLAW_HOME` | Sovrascrive la directory radice per i dati di picoclaw. Modifica la posizione predefinita del `workspace` e delle altre directory dati. | `~/.picoclaw` |
|
|
||||||
|
|
||||||
**Esempi:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Esegui picoclaw usando un file di configurazione specifico
|
|
||||||
# Il percorso del workspace verrà letto da quel file di configurazione
|
|
||||||
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
|
||||||
|
|
||||||
# Esegui picoclaw con tutti i dati salvati in /opt/picoclaw
|
|
||||||
# La configurazione verrà caricata dal percorso predefinito ~/.picoclaw/config.json
|
|
||||||
# Il workspace verrà creato in /opt/picoclaw/workspace
|
|
||||||
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
|
||||||
|
|
||||||
# Usa entrambi per un setup completamente personalizzato
|
|
||||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
|
||||||
```
|
|
||||||
|
|
||||||
### Struttura del Workspace
|
|
||||||
|
|
||||||
PicoClaw salva i dati nel workspace configurato (predefinito: `~/.picoclaw/workspace`):
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.picoclaw/workspace/
|
|
||||||
├── sessions/ # Sessioni di conversazione e cronologia
|
|
||||||
├── memory/ # Memoria a lungo termine (MEMORY.md)
|
|
||||||
├── state/ # Stato persistente (ultimo canale, ecc.)
|
|
||||||
├── cron/ # Database dei job pianificati
|
|
||||||
├── skills/ # Skill personalizzate
|
|
||||||
├── AGENTS.md # Guida al comportamento dell'agent
|
|
||||||
├── HEARTBEAT.md # Prompt per task periodici (controllato ogni 30 min)
|
|
||||||
├── IDENTITY.md # Identità dell'agent
|
|
||||||
├── SOUL.md # Anima dell'agent
|
|
||||||
└── USER.md # Preferenze dell'utente
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Nota:** Le modifiche a `AGENTS.md`, `SOUL.md`, `USER.md`, `IDENTITY.md` e `memory/MEMORY.md` vengono rilevate automaticamente a runtime tramite il tracciamento della data di modifica (mtime). **Non è necessario riavviare il gateway** dopo aver modificato questi file — l'agent caricherà il nuovo contenuto alla prossima richiesta.
|
|
||||||
|
|
||||||
### Sorgenti delle Skill
|
|
||||||
|
|
||||||
Per impostazione predefinita, le skill vengono caricate da:
|
|
||||||
|
|
||||||
1. `~/.picoclaw/workspace/skills` (workspace)
|
|
||||||
2. `~/.picoclaw/skills` (globale)
|
|
||||||
3. `<current-working-directory>/skills` (builtin)
|
|
||||||
|
|
||||||
Per configurazioni avanzate/di test, puoi sovrascrivere la directory radice delle skill builtin con:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
|
||||||
```
|
|
||||||
|
|
||||||
### Politica Unificata di Esecuzione dei Comandi
|
|
||||||
|
|
||||||
- I comandi slash generici vengono eseguiti tramite un unico percorso in `pkg/agent/loop.go` via `commands.Executor`.
|
|
||||||
- Gli adattatori dei canali non consumano più localmente i comandi generici; inoltrano il testo in entrata al percorso bus/agent. Telegram registra ancora automaticamente i comandi supportati all'avvio.
|
|
||||||
- Un comando slash sconosciuto (ad esempio `/foo`) viene passato all'elaborazione LLM come se fosse un messaggio dell'utente.
|
|
||||||
- Un comando registrato ma non supportato sul canale corrente (ad esempio `/show` su WhatsApp) restituisce un errore esplicito all'utente e interrompe l'elaborazione.
|
|
||||||
|
|
||||||
### 🔒 Sandbox di Sicurezza
|
|
||||||
|
|
||||||
PicoClaw esegue in un ambiente sandboxed per impostazione predefinita. L'agent può accedere solo ai file ed eseguire comandi all'interno del workspace configurato.
|
|
||||||
|
|
||||||
#### Configurazione Predefinita
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"workspace": "~/.picoclaw/workspace",
|
|
||||||
"restrict_to_workspace": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Opzione | Predefinito | Descrizione |
|
|
||||||
| ----------------------- | ----------------------- | ---------------------------------------------------- |
|
|
||||||
| `workspace` | `~/.picoclaw/workspace` | Directory di lavoro dell'agent |
|
|
||||||
| `restrict_to_workspace` | `true` | Limita l'accesso a file/comandi al workspace |
|
|
||||||
|
|
||||||
#### Strumenti Protetti
|
|
||||||
|
|
||||||
Quando `restrict_to_workspace: true`, i seguenti strumenti sono in sandbox:
|
|
||||||
|
|
||||||
| Strumento | Funzione | Restrizione |
|
|
||||||
| ------------- | ------------------------- | ---------------------------------------------------- |
|
|
||||||
| `read_file` | Legge file | Solo file all'interno del workspace |
|
|
||||||
| `write_file` | Scrive file | Solo file all'interno del workspace |
|
|
||||||
| `list_dir` | Elenca directory | Solo directory all'interno del workspace |
|
|
||||||
| `edit_file` | Modifica file | Solo file all'interno del workspace |
|
|
||||||
| `append_file` | Aggiunge ai file | Solo file all'interno del workspace |
|
|
||||||
| `exec` | Esegue comandi | I percorsi dei comandi devono essere nel workspace |
|
|
||||||
|
|
||||||
#### Protezione Exec Aggiuntiva
|
|
||||||
|
|
||||||
Anche con `restrict_to_workspace: false`, lo strumento `exec` blocca questi comandi pericolosi:
|
|
||||||
|
|
||||||
* `rm -rf`, `del /f`, `rmdir /s` — Cancellazione di massa
|
|
||||||
* `format`, `mkfs`, `diskpart` — Formattazione del disco
|
|
||||||
* `dd if=` — Imaging del disco
|
|
||||||
* Scrittura su `/dev/sd[a-z]` — Scritture dirette su disco
|
|
||||||
* `shutdown`, `reboot`, `poweroff` — Spegnimento del sistema
|
|
||||||
* Fork bomb `:(){ :|:& };:`
|
|
||||||
|
|
||||||
### Controllo Accesso ai File
|
|
||||||
|
|
||||||
| Chiave di configurazione | Tipo | Predefinito | Descrizione |
|
|
||||||
|--------------------------|------|-------------|-------------|
|
|
||||||
| `tools.allow_read_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la lettura al di fuori del workspace |
|
|
||||||
| `tools.allow_write_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la scrittura al di fuori del workspace |
|
|
||||||
|
|
||||||
### Sicurezza Exec
|
|
||||||
|
|
||||||
| Chiave di configurazione | Tipo | Predefinito | Descrizione |
|
|
||||||
|--------------------------|------|-------------|-------------|
|
|
||||||
| `tools.exec.allow_remote` | bool | `false` | Consente lo strumento exec da canali remoti (Telegram/Discord ecc.) |
|
|
||||||
| `tools.exec.enable_deny_patterns` | bool | `true` | Abilita l'intercettazione dei comandi pericolosi |
|
|
||||||
| `tools.exec.custom_deny_patterns` | string[] | `[]` | Pattern regex personalizzati da bloccare |
|
|
||||||
| `tools.exec.custom_allow_patterns` | string[] | `[]` | Pattern regex personalizzati da consentire |
|
|
||||||
|
|
||||||
> **Nota di sicurezza:** La protezione dei symlink è abilitata per impostazione predefinita — tutti i percorsi file vengono risolti tramite `filepath.EvalSymlinks` prima del confronto con la whitelist, prevenendo attacchi di escape tramite symlink.
|
|
||||||
|
|
||||||
#### Limitazione Nota: Processi Figlio degli Strumenti di Build
|
|
||||||
|
|
||||||
Il controllo di sicurezza exec ispeziona solo la riga di comando avviata direttamente da PicoClaw. Non ispeziona ricorsivamente i processi figlio generati da strumenti di sviluppo consentiti come `make`, `go run`, `cargo`, `npm run` o script di build personalizzati.
|
|
||||||
|
|
||||||
Ciò significa che un comando di primo livello può comunque compilare o avviare altri binari dopo aver superato il controllo iniziale. In pratica, tratta gli script di build, i Makefile, gli script di pacchetti e i binari generati come codice eseguibile che richiede lo stesso livello di revisione di un comando shell diretto.
|
|
||||||
|
|
||||||
Per ambienti ad alto rischio:
|
|
||||||
|
|
||||||
* Esamina gli script di build prima dell'esecuzione.
|
|
||||||
* Preferisci l'approvazione/revisione manuale per i workflow di compilazione ed esecuzione.
|
|
||||||
* Esegui PicoClaw in un container o VM se hai bisogno di un isolamento più forte di quello fornito dal controllo integrato.
|
|
||||||
|
|
||||||
#### Esempi di Errore
|
|
||||||
|
|
||||||
```
|
|
||||||
[ERROR] tool: Tool execution failed
|
|
||||||
{tool=exec, error=Command blocked by safety guard (path outside working dir)}
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
[ERROR] tool: Tool execution failed
|
|
||||||
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Disabilitare le Restrizioni (Rischio di Sicurezza)
|
|
||||||
|
|
||||||
Se hai bisogno che l'agent acceda a percorsi al di fuori del workspace:
|
|
||||||
|
|
||||||
**Metodo 1: File di configurazione**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"restrict_to_workspace": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Metodo 2: Variabile d'ambiente**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ **Attenzione**: Disabilitare questa restrizione consente all'agent di accedere a qualsiasi percorso sul tuo sistema. Usare con cautela solo in ambienti controllati.
|
|
||||||
|
|
||||||
#### Coerenza dei Confini di Sicurezza
|
|
||||||
|
|
||||||
L'impostazione `restrict_to_workspace` si applica in modo coerente a tutti i percorsi di esecuzione:
|
|
||||||
|
|
||||||
| Percorso di esecuzione | Confine di sicurezza |
|
|
||||||
| ---------------------- | --------------------------------- |
|
|
||||||
| Main Agent | `restrict_to_workspace` ✅ |
|
|
||||||
| Subagent / Spawn | Eredita la stessa restrizione ✅ |
|
|
||||||
| Heartbeat tasks | Eredita la stessa restrizione ✅ |
|
|
||||||
|
|
||||||
Tutti i percorsi condividono la stessa restrizione del workspace — non è possibile aggirare il confine di sicurezza tramite subagent o task pianificati.
|
|
||||||
|
|
||||||
### Heartbeat (Task Periodici)
|
|
||||||
|
|
||||||
PicoClaw può eseguire task periodici automaticamente. Crea un file `HEARTBEAT.md` nel tuo workspace:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Periodic Tasks
|
|
||||||
|
|
||||||
- Check my email for important messages
|
|
||||||
- Review my calendar for upcoming events
|
|
||||||
- Check the weather forecast
|
|
||||||
```
|
|
||||||
|
|
||||||
L'agent leggerà questo file ogni 30 minuti (configurabile) ed eseguirà tutti i task usando gli strumenti disponibili.
|
|
||||||
|
|
||||||
#### Task Asincroni con Spawn
|
|
||||||
|
|
||||||
Per task di lunga durata (ricerca web, chiamate API), usa lo strumento `spawn` per creare un **subagent**:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Periodic Tasks
|
|
||||||
```
|
|
||||||
|
|
@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Gateway ログレベル
|
||||||
|
|
||||||
|
`gateway.log_level` は Gateway のログ詳細度を制御します。`config.json` で設定できます:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gateway": {
|
||||||
|
"log_level": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
デフォルト値は `warn` です。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。
|
||||||
|
|
||||||
|
環境変数でも上書き可能です:`PICOCLAW_LOG_LEVEL=info`
|
||||||
|
|
||||||
### ワークスペースレイアウト
|
### ワークスペースレイアウト
|
||||||
|
|
||||||
PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します:
|
PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します:
|
||||||
|
|
@ -319,15 +335,15 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" },
|
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] },
|
||||||
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" }
|
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 旧 `providers` 設定からの移行
|
#### 旧 `providers` 設定からの移行
|
||||||
|
|
||||||
旧 `providers` 設定は**非推奨**ですが後方互換性のためサポートされています。[docs/migration/model-list-migration.md](../migration/model-list-migration.md) を参照してください。
|
旧 `providers` 設定は**非推奨**となり、V2 で削除されました。既存の V0/V1 設定は自動的に移行されます。[docs/migration/model-list-migration.md](../migration/model-list-migration.md) を参照してください。
|
||||||
|
|
||||||
### Provider アーキテクチャ
|
### Provider アーキテクチャ
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,19 +94,19 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key",
|
"api_keys": ["sk-your-api-key"],
|
||||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "your-api-key",
|
"api_keys": ["your-api-key"],
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "your-anthropic-key"
|
"api_keys": ["your-anthropic-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
|
||||||
|
|
@ -73,22 +73,22 @@
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key"
|
"api_keys": ["sk-your-api-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_keys": ["sk-your-openai-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key"
|
"api_keys": ["sk-ant-your-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-zhipu-key"
|
"api_keys": ["your-zhipu-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
@ -99,6 +99,24 @@
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `model_list` エントリフィールド
|
||||||
|
|
||||||
|
| フィールド | 型 | 必須 | 説明 |
|
||||||
|
|-----------|------|------|------|
|
||||||
|
| `model_name` | string | はい | agent 設定でこのモデルを参照するための一意の名前 |
|
||||||
|
| `model` | string | はい | ベンダー/モデル識別子(例:`openai/gpt-5.4`、`azure/gpt-5.4`、`anthropic/claude-sonnet-4.6`) |
|
||||||
|
| `api_keys` | string[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル provider(Ollama、LM Studio、VLLM)には不要 |
|
||||||
|
| `api_base` | string | いいえ | デフォルトの API エンドポイント URL を上書き |
|
||||||
|
| `proxy` | string | いいえ | このモデルエントリの HTTP プロキシ URL |
|
||||||
|
| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Anthropic、Azure provider で対応) |
|
||||||
|
| `request_timeout` | int | いいえ | リクエストタイムアウト(秒)。デフォルト値は provider により異なる |
|
||||||
|
| `max_tokens_field` | string | いいえ | リクエストボディの max tokens フィールド名を上書き(例:o1 モデルでは `max_completion_tokens`) |
|
||||||
|
| `thinking_level` | string | いいえ | 拡張思考レベル:`off`、`low`、`medium`、`high`、`xhigh`、`adaptive` |
|
||||||
|
| `extra_body` | object | いいえ | 各リクエストボディに注入する追加フィールド |
|
||||||
|
| `rpm` | int | いいえ | 1 分あたりのリクエストレート制限 |
|
||||||
|
| `fallbacks` | string[] | いいえ | 自動フェイルオーバーのフォールバックモデル名 |
|
||||||
|
| `enabled` | bool | いいえ | このモデルエントリを有効にするかどうか(デフォルト:`true`) |
|
||||||
|
|
||||||
#### ベンダー別設定例
|
#### ベンダー別設定例
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
|
|
@ -107,7 +125,7 @@
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -117,7 +135,7 @@
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -127,7 +145,18 @@
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-key"
|
"api_keys": ["your-key"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**LiteLLM Proxy**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "lite-gpt4",
|
||||||
|
"model": "litellm/lite-gpt4",
|
||||||
|
"api_base": "http://localhost:4000/v1",
|
||||||
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -137,7 +166,7 @@
|
||||||
{
|
{
|
||||||
"model_name": "deepseek-chat",
|
"model_name": "deepseek-chat",
|
||||||
"model": "deepseek/deepseek-chat",
|
"model": "deepseek/deepseek-chat",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -147,7 +176,7 @@
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key"
|
"api_keys": ["sk-ant-your-key"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -161,7 +190,7 @@ Anthropic API への直接アクセスや、Anthropic のネイティブメッ
|
||||||
{
|
{
|
||||||
"model_name": "claude-opus-4-6",
|
"model_name": "claude-opus-4-6",
|
||||||
"model": "anthropic-messages/claude-opus-4-6",
|
"model": "anthropic-messages/claude-opus-4-6",
|
||||||
"api_key": "sk-ant-your-key",
|
"api_keys": ["sk-ant-your-key"],
|
||||||
"api_base": "https://api.anthropic.com"
|
"api_base": "https://api.anthropic.com"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -189,7 +218,8 @@ Anthropic API への直接アクセスや、Anthropic のネイティブメッ
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-...",
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -201,7 +231,7 @@ Anthropic API への直接アクセスや、Anthropic のネイティブメッ
|
||||||
"model_name": "lite-gpt4",
|
"model_name": "lite-gpt4",
|
||||||
"model": "litellm/lite-gpt4",
|
"model": "litellm/lite-gpt4",
|
||||||
"api_base": "http://localhost:4000/v1",
|
"api_base": "http://localhost:4000/v1",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -218,13 +248,13 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_keys": ["sk-key1"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_keys": ["sk-key2"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -232,7 +262,7 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック
|
||||||
|
|
||||||
#### レガシー `providers` 設定からの移行
|
#### レガシー `providers` 設定からの移行
|
||||||
|
|
||||||
旧 `providers` 設定形式は**非推奨**ですが、後方互換性のためまだサポートされています。
|
旧 `providers` 設定形式は**非推奨**となり、V2 で削除されました。既存の V0/V1 設定は自動的に移行されます。
|
||||||
|
|
||||||
**旧設定(非推奨):**
|
**旧設定(非推奨):**
|
||||||
|
|
||||||
|
|
@ -257,11 +287,12 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"version": 2,
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-key"
|
"api_keys": ["your-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
@ -282,7 +313,7 @@ PicoClaw はプロトコルファミリーごとに Provider をルーティン
|
||||||
- Anthropic プロトコル:Claude ネイティブ API 動作。
|
- Anthropic プロトコル:Claude ネイティブ API 動作。
|
||||||
- Codex/OAuth パス:OpenAI OAuth/Token 認証ルート。
|
- Codex/OAuth パス:OpenAI OAuth/Token 認証ルート。
|
||||||
|
|
||||||
これによりランタイムを軽量に保ちつつ、新しい OpenAI 互換バックエンドの追加をほぼ設定操作(`api_base` + `api_key`)のみで実現しています。
|
これによりランタイムを軽量に保ちつつ、新しい OpenAI 互換バックエンドの追加をほぼ設定操作(`api_base` + `api_keys`)のみで実現しています。
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Zhipu 設定例</b></summary>
|
<summary><b>Zhipu 設定例</b></summary>
|
||||||
|
|
|
||||||
|
|
@ -50,22 +50,23 @@ The new `model_list` configuration offers several advantages:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"version": 2,
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_keys": ["sk-your-openai-key"],
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key"
|
"api_keys": ["sk-ant-your-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "deepseek",
|
"model_name": "deepseek",
|
||||||
"model": "deepseek/deepseek-chat",
|
"model": "deepseek/deepseek-chat",
|
||||||
"api_key": "sk-your-deepseek-key"
|
"api_keys": ["sk-your-deepseek-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
@ -76,6 +77,8 @@ The new `model_list` configuration offers several advantages:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note**: The `enabled` field can be omitted — during V1→V2 migration it is auto-inferred (models with API keys or the `local-model` name are enabled by default). For new configs, you can explicitly set `"enabled": false` to disable a model entry without removing it.
|
||||||
|
|
||||||
## Protocol Prefixes
|
## Protocol Prefixes
|
||||||
|
|
||||||
The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
||||||
|
|
@ -111,7 +114,8 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
||||||
| `model_name` | Yes | User-facing alias for the model |
|
| `model_name` | Yes | User-facing alias for the model |
|
||||||
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.4`) |
|
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.4`) |
|
||||||
| `api_base` | No | API endpoint URL |
|
| `api_base` | No | API endpoint URL |
|
||||||
| `api_key` | No* | API authentication key |
|
| `api_keys` | No | API authentication keys (array; supports multiple keys for load balancing) |
|
||||||
|
| `enabled` | No | Whether this model entry is active. Defaults to `true` during migration for models with API keys or named `local-model`. Set to `false` to disable. |
|
||||||
| `proxy` | No | HTTP proxy URL |
|
| `proxy` | No | HTTP proxy URL |
|
||||||
| `auth_method` | No | Authentication method: `oauth`, `token` |
|
| `auth_method` | No | Authentication method: `oauth`, `token` |
|
||||||
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
|
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
|
||||||
|
|
@ -119,11 +123,13 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
||||||
| `max_tokens_field` | No | Field name for max tokens |
|
| `max_tokens_field` | No | Field name for max tokens |
|
||||||
| `request_timeout` | No | HTTP request timeout in seconds; `<=0` uses default `120s` |
|
| `request_timeout` | No | HTTP request timeout in seconds; `<=0` uses default `120s` |
|
||||||
|
|
||||||
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
|
> **Note**: `api_key` (singular) has been **removed** in V2 configs. Only `api_keys` (array) is supported. During migration from V0/V1, both `api_key` and `api_keys` are automatically merged into the new `api_keys` array.
|
||||||
|
|
||||||
## Load Balancing
|
## Load Balancing
|
||||||
|
|
||||||
Configure multiple endpoints for the same model to distribute load:
|
There are two ways to configure load balancing:
|
||||||
|
|
||||||
|
### Option 1: Multiple API Keys in `api_keys` (Recommended)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -131,19 +137,45 @@ Configure multiple endpoints for the same model to distribute load:
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key1",
|
"api_keys": ["sk-key1", "sk-key2", "sk-key3"],
|
||||||
|
"api_base": "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via `.security.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
gpt4:
|
||||||
|
api_keys:
|
||||||
|
- "sk-key1"
|
||||||
|
- "sk-key2"
|
||||||
|
- "sk-key3"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Multiple Model Entries
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_keys": ["sk-key1"],
|
||||||
"api_base": "https://api1.example.com/v1"
|
"api_base": "https://api1.example.com/v1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key2",
|
"api_keys": ["sk-key2"],
|
||||||
"api_base": "https://api2.example.com/v1"
|
"api_base": "https://api2.example.com/v1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key3",
|
"api_keys": ["sk-key3"],
|
||||||
"api_base": "https://api3.example.com/v1"
|
"api_base": "https://api3.example.com/v1"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -162,7 +194,7 @@ With `model_list`, adding a new provider requires zero code changes:
|
||||||
{
|
{
|
||||||
"model_name": "my-custom-llm",
|
"model_name": "my-custom-llm",
|
||||||
"model": "openai/my-model-v1",
|
"model": "openai/my-model-v1",
|
||||||
"api_key": "your-api-key",
|
"api_keys": ["your-api-key"],
|
||||||
"api_base": "https://api.your-provider.com/v1"
|
"api_base": "https://api.your-provider.com/v1"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -173,11 +205,12 @@ Just specify `openai/` as the protocol (or omit it for the default), and provide
|
||||||
|
|
||||||
## Backward Compatibility
|
## Backward Compatibility
|
||||||
|
|
||||||
During the migration period, your existing `providers` configuration will continue to work:
|
During the migration period, your existing V0/V1 config will be auto-migrated to V2:
|
||||||
|
|
||||||
1. If `model_list` is empty and `providers` has data, the system auto-converts internally
|
1. If `model_list` is empty and `providers` has data, the system auto-converts internally
|
||||||
2. A deprecation warning is logged: `"providers config is deprecated, please migrate to model_list"`
|
2. Both `api_key` (singular) and `api_keys` (array) in V0/V1 configs are merged into the new `api_keys` array
|
||||||
3. All existing functionality remains unchanged
|
3. A deprecation warning is logged: `"providers config is deprecated, please migrate to model_list"`
|
||||||
|
4. All existing functionality remains unchanged
|
||||||
|
|
||||||
## Migration Checklist
|
## Migration Checklist
|
||||||
|
|
||||||
|
|
@ -212,7 +245,7 @@ unknown protocol "xxx" in model "xxx/model-name"
|
||||||
api_key or api_base is required for HTTP-based protocol "xxx"
|
api_key or api_base is required for HTTP-based protocol "xxx"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Solution**: Provide `api_key` and/or `api_base` for HTTP-based providers.
|
**Solution**: Provide `api_keys` and/or `api_base` for HTTP-based providers.
|
||||||
|
|
||||||
## Need Help?
|
## Need Help?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,19 +91,19 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key",
|
"api_keys": ["sk-your-api-key"],
|
||||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "your-api-key",
|
"api_keys": ["your-api-key"],
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "your-anthropic-key"
|
"api_keys": ["your-anthropic-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||||
|
| `venice` | LLM (Venice AI direct) | [venice.ai](https://venice.ai) |
|
||||||
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||||
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||||
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||||
|
|
@ -46,6 +47,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
||||||
| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
|
| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
|
||||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||||
|
| **Venice AI** | `venice/` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) |
|
||||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
| **Z.AI Coding Plan** | `openai/` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) |
|
| **Z.AI Coding Plan** | `openai/` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) |
|
||||||
|
|
@ -56,6 +58,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
||||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
||||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
||||||
|
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) |
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
||||||
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
|
|
@ -79,22 +82,22 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key"
|
"api_keys": ["sk-your-api-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_keys": ["sk-your-openai-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key"
|
"api_keys": ["sk-ant-your-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-zhipu-key"
|
"api_keys": ["your-zhipu-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
@ -105,6 +108,24 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `model_list` Entry Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `model_name` | string | Yes | Unique name used to reference this model in agent config |
|
||||||
|
| `model` | string | Yes | Vendor/model identifier (e.g., `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
|
||||||
|
| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) |
|
||||||
|
| `api_base` | string | No | Override the default API endpoint URL |
|
||||||
|
| `proxy` | string | No | HTTP proxy URL for this model entry |
|
||||||
|
| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Anthropic, and Azure providers) |
|
||||||
|
| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) |
|
||||||
|
| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) |
|
||||||
|
| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` |
|
||||||
|
| `extra_body` | object | No | Additional fields to inject into every request body |
|
||||||
|
| `rpm` | int | No | Per-minute request rate limit |
|
||||||
|
| `fallbacks` | string[] | No | Fallback model names for automatic failover |
|
||||||
|
| `enabled` | bool | No | Whether this model entry is active (default: `true`) |
|
||||||
|
|
||||||
#### Voice Transcription
|
#### Voice Transcription
|
||||||
|
|
||||||
You can configure a dedicated model for audio transcription with `voice.model_name`. This lets you reuse existing multimodal providers that support audio input instead of relying only on Groq.
|
You can configure a dedicated model for audio transcription with `voice.model_name`. This lets you reuse existing multimodal providers that support audio input instead of relying only on Groq.
|
||||||
|
|
@ -117,7 +138,7 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
||||||
{
|
{
|
||||||
"model_name": "voice-gemini",
|
"model_name": "voice-gemini",
|
||||||
"model": "gemini/gemini-2.5-flash",
|
"model": "gemini/gemini-2.5-flash",
|
||||||
"api_key": "your-gemini-key"
|
"api_keys": ["your-gemini-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"voice": {
|
"voice": {
|
||||||
|
|
@ -140,7 +161,7 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -150,7 +171,7 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -160,7 +181,7 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-key"
|
"api_keys": ["your-key"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -170,7 +191,7 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "openai/glm-4.7",
|
"model": "openai/glm-4.7",
|
||||||
"api_key": "your-z.ai-key"
|
"api_keys": ["your-z.ai-key"],
|
||||||
"api_base": "https://api.z.ai/api/coding/paas/v4"
|
"api_base": "https://api.z.ai/api/coding/paas/v4"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -181,7 +202,7 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
||||||
{
|
{
|
||||||
"model_name": "deepseek-chat",
|
"model_name": "deepseek-chat",
|
||||||
"model": "deepseek/deepseek-chat",
|
"model": "deepseek/deepseek-chat",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -191,7 +212,7 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key"
|
"api_keys": ["sk-ant-your-key"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -205,7 +226,7 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
|
||||||
{
|
{
|
||||||
"model_name": "claude-opus-4-6",
|
"model_name": "claude-opus-4-6",
|
||||||
"model": "anthropic-messages/claude-opus-4-6",
|
"model": "anthropic-messages/claude-opus-4-6",
|
||||||
"api_key": "sk-ant-your-key",
|
"api_keys": ["sk-ant-your-key"],
|
||||||
"api_base": "https://api.anthropic.com"
|
"api_base": "https://api.anthropic.com"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -226,6 +247,18 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**LM Studio (local)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "lmstudio-local",
|
||||||
|
"model": "lmstudio/openai/gpt-oss-20b"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.<br/>
|
||||||
|
PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio/` prefix before sending requests, so `lmstudio/openai/gpt-oss-20b` sends `openai/gpt-oss-20b` to the LM Studio server.
|
||||||
|
|
||||||
**Custom Proxy/API**
|
**Custom Proxy/API**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -233,7 +266,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-...",
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -245,7 +279,7 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
|
||||||
"model_name": "lite-gpt4",
|
"model_name": "lite-gpt4",
|
||||||
"model": "litellm/lite-gpt4",
|
"model": "litellm/lite-gpt4",
|
||||||
"api_base": "http://localhost:4000/v1",
|
"api_base": "http://localhost:4000/v1",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -259,7 +293,7 @@ If the standard Zhipu endpoint (`https://open.bigmodel.cn/api/paas/v4`) returns
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "openai/glm-4.7",
|
"model": "openai/glm-4.7",
|
||||||
"api_key": "your-zhipu-api-key",
|
"api_keys": ["your-zhipu-api-key"],
|
||||||
"api_base": "https://api.z.ai/api/coding/paas/v4"
|
"api_base": "https://api.z.ai/api/coding/paas/v4"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -277,13 +311,13 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_keys": ["sk-key1"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_keys": ["sk-key2"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -302,17 +336,17 @@ It also applies cooldown tracking per candidate to avoid immediately retrying a
|
||||||
"model_name": "qwen-main",
|
"model_name": "qwen-main",
|
||||||
"model": "openai/qwen3.5:cloud",
|
"model": "openai/qwen3.5:cloud",
|
||||||
"api_base": "https://api.example.com/v1",
|
"api_base": "https://api.example.com/v1",
|
||||||
"api_key": "sk-main"
|
"api_keys": ["sk-main"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "deepseek-backup",
|
"model_name": "deepseek-backup",
|
||||||
"model": "deepseek/deepseek-chat",
|
"model": "deepseek/deepseek-chat",
|
||||||
"api_key": "sk-backup-1"
|
"api_keys": ["sk-backup-1"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gemini-backup",
|
"model_name": "gemini-backup",
|
||||||
"model": "gemini/gemini-2.5-flash",
|
"model": "gemini/gemini-2.5-flash",
|
||||||
"api_key": "sk-backup-2"
|
"api_keys": ["sk-backup-2"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
@ -330,7 +364,7 @@ If you use key-level failover for the same model, PicoClaw can chain through add
|
||||||
|
|
||||||
#### Migration from Legacy `providers` Config
|
#### Migration from Legacy `providers` Config
|
||||||
|
|
||||||
The old `providers` configuration is **deprecated** but still supported for backward compatibility.
|
The old `providers` configuration is **deprecated** and has been removed in V2. Existing V0/V1 configs are auto-migrated.
|
||||||
|
|
||||||
**Old Config (deprecated):**
|
**Old Config (deprecated):**
|
||||||
|
|
||||||
|
|
@ -355,11 +389,12 @@ The old `providers` configuration is **deprecated** but still supported for back
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"version": 2,
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-key"
|
"api_keys": ["your-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Nível de Log do Gateway
|
||||||
|
|
||||||
|
`gateway.log_level` controla a verbosidade dos logs do Gateway, configurável em `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gateway": {
|
||||||
|
"log_level": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
O valor padrão é `warn`. Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`.
|
||||||
|
|
||||||
|
Também pode ser substituído pela variável de ambiente: `PICOCLAW_LOG_LEVEL=info`
|
||||||
|
|
||||||
### Layout do Workspace
|
### Layout do Workspace
|
||||||
|
|
||||||
O PicoClaw armazena dados no seu workspace configurado (padrão: `~/.picoclaw/workspace`):
|
O PicoClaw armazena dados no seu workspace configurado (padrão: `~/.picoclaw/workspace`):
|
||||||
|
|
@ -319,15 +335,15 @@ Configure múltiplos endpoints para o mesmo nome de modelo — PicoClaw fará ro
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" },
|
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] },
|
||||||
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" }
|
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Migração da Configuração Legada `providers`
|
#### Migração da Configuração Legada `providers`
|
||||||
|
|
||||||
A configuração antiga `providers` está **depreciada** mas ainda é suportada. Veja [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
|
A configuração antiga `providers` está **depreciada** e foi removida no V2. Configs V0/V1 existentes são auto-migradas. Veja [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
|
||||||
|
|
||||||
### Arquitetura de Providers
|
### Arquitetura de Providers
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,19 +92,19 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key",
|
"api_keys": ["sk-your-api-key"],
|
||||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "your-api-key",
|
"api_keys": ["your-api-key"],
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "your-anthropic-key"
|
"api_keys": ["your-anthropic-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
|
||||||
|
|
@ -73,22 +73,22 @@ Este design também permite **suporte multi-agente** com seleção flexível de
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key"
|
"api_keys": ["sk-your-api-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_keys": ["sk-your-openai-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key"
|
"api_keys": ["sk-ant-your-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-zhipu-key"
|
"api_keys": ["your-zhipu-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
@ -99,6 +99,24 @@ Este design também permite **suporte multi-agente** com seleção flexível de
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Campos de entrada `model_list`
|
||||||
|
|
||||||
|
| Campo | Tipo | Obrigatório | Descrição |
|
||||||
|
|-------|------|-------------|-----------|
|
||||||
|
| `model_name` | string | Sim | Nome único para referenciar este modelo na config do agent |
|
||||||
|
| `model` | string | Sim | Identificador fornecedor/modelo (ex: `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
|
||||||
|
| `api_keys` | string[] | Sim* | Chave(s) API para autenticação. Múltiplas chaves permitem rotação por requisição. Não necessário para providers locais (Ollama, LM Studio, VLLM) |
|
||||||
|
| `api_base` | string | Não | Substitui a URL base da API padrão |
|
||||||
|
| `proxy` | string | Não | URL do proxy HTTP para esta entrada de modelo |
|
||||||
|
| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Anthropic e Azure) |
|
||||||
|
| `request_timeout` | int | Não | Timeout de requisição em segundos (o padrão varia por provider) |
|
||||||
|
| `max_tokens_field` | string | Não | Substitui o nome do campo max tokens no corpo da requisição (ex: `max_completion_tokens` para modelos o1) |
|
||||||
|
| `thinking_level` | string | Não | Nível de pensamento estendido: `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` |
|
||||||
|
| `extra_body` | object | Não | Campos adicionais para injetar em cada corpo de requisição |
|
||||||
|
| `rpm` | int | Não | Limite de requisições por minuto |
|
||||||
|
| `fallbacks` | string[] | Não | Nomes dos modelos de fallback para failover automático |
|
||||||
|
| `enabled` | bool | Não | Ativar ou desativar esta entrada de modelo (padrão: `true`) |
|
||||||
|
|
||||||
#### Exemplos por Vendor
|
#### Exemplos por Vendor
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
|
|
@ -107,7 +125,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -117,7 +135,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -127,7 +145,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-key"
|
"api_keys": ["your-key"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -137,7 +155,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de
|
||||||
{
|
{
|
||||||
"model_name": "deepseek-chat",
|
"model_name": "deepseek-chat",
|
||||||
"model": "deepseek/deepseek-chat",
|
"model": "deepseek/deepseek-chat",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -147,7 +165,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key"
|
"api_keys": ["sk-ant-your-key"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -161,7 +179,7 @@ Para acesso direto à API Anthropic ou endpoints personalizados que suportam ape
|
||||||
{
|
{
|
||||||
"model_name": "claude-opus-4-6",
|
"model_name": "claude-opus-4-6",
|
||||||
"model": "anthropic-messages/claude-opus-4-6",
|
"model": "anthropic-messages/claude-opus-4-6",
|
||||||
"api_key": "sk-ant-your-key",
|
"api_keys": ["sk-ant-your-key"],
|
||||||
"api_base": "https://api.anthropic.com"
|
"api_base": "https://api.anthropic.com"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -189,7 +207,8 @@ Para acesso direto à API Anthropic ou endpoints personalizados que suportam ape
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-...",
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -201,7 +220,7 @@ Para acesso direto à API Anthropic ou endpoints personalizados que suportam ape
|
||||||
"model_name": "lite-gpt4",
|
"model_name": "lite-gpt4",
|
||||||
"model": "litellm/lite-gpt4",
|
"model": "litellm/lite-gpt4",
|
||||||
"api_base": "http://localhost:4000/v1",
|
"api_base": "http://localhost:4000/v1",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -218,13 +237,13 @@ Configure múltiplos endpoints para o mesmo nome de modelo — o PicoClaw fará
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_keys": ["sk-key1"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_keys": ["sk-key2"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -232,7 +251,7 @@ Configure múltiplos endpoints para o mesmo nome de modelo — o PicoClaw fará
|
||||||
|
|
||||||
#### Migração da Configuração Legacy `providers`
|
#### Migração da Configuração Legacy `providers`
|
||||||
|
|
||||||
A configuração antiga `providers` está **descontinuada** mas ainda é suportada para compatibilidade retroativa.
|
A configuração antiga `providers` está **descontinuada** e foi removida no V2. Configs V0/V1 existentes são auto-migradas.
|
||||||
|
|
||||||
**Configuração Antiga (descontinuada):**
|
**Configuração Antiga (descontinuada):**
|
||||||
|
|
||||||
|
|
@ -257,11 +276,12 @@ A configuração antiga `providers` está **descontinuada** mas ainda é suporta
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"version": 2,
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-key"
|
"api_keys": ["your-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
@ -282,7 +302,7 @@ O PicoClaw roteia provedores por família de protocolo:
|
||||||
- Protocolo Anthropic: Comportamento nativo da API Claude.
|
- Protocolo Anthropic: Comportamento nativo da API Claude.
|
||||||
- Caminho Codex/OAuth: Rota de autenticação OAuth/token da OpenAI.
|
- Caminho Codex/OAuth: Rota de autenticação OAuth/token da OpenAI.
|
||||||
|
|
||||||
Isso mantém o runtime leve enquanto torna novos backends compatíveis com OpenAI basicamente uma operação de configuração (`api_base` + `api_key`).
|
Isso mantém o runtime leve enquanto torna novos backends compatíveis com OpenAI basicamente uma operação de configuração (`api_base` + `api_keys`).
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Zhipu</b></summary>
|
<summary><b>Zhipu</b></summary>
|
||||||
|
|
|
||||||
95
docs/rate-limiting.md
Normal file
95
docs/rate-limiting.md
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
# Dynamic Rate Limiting
|
||||||
|
|
||||||
|
PicoClaw prevents 429 errors from LLM provider APIs by enforcing configurable per-model request-rate limits **before** sending each request. Unlike the reactive cooldown/fallback system (which activates *after* a 429 is received), rate limiting is **proactive**: it keeps outbound QPS within the provider's free-tier or plan limits.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
### Token-bucket algorithm
|
||||||
|
|
||||||
|
Each rate-limited model gets a token bucket:
|
||||||
|
|
||||||
|
- **Capacity** = `rpm` (burst size equals the per-minute limit)
|
||||||
|
- **Refill rate** = `rpm / 60` tokens per second
|
||||||
|
- Tokens are consumed one per LLM call; if the bucket is empty, the call blocks until a token refills or the request context is cancelled
|
||||||
|
|
||||||
|
### Call chain integration
|
||||||
|
|
||||||
|
```
|
||||||
|
AgentLoop.callLLM()
|
||||||
|
└─ FallbackChain.Execute() ← iterate candidates
|
||||||
|
├─ CooldownTracker.IsAvailable() ← skip if post-429 cooldown active
|
||||||
|
├─ RateLimiterRegistry.Wait() ← NEW: block until token available
|
||||||
|
└─ provider.Chat() ← actual LLM HTTP call
|
||||||
|
```
|
||||||
|
|
||||||
|
The rate limiter runs **after** the cooldown check and **before** the provider call, so:
|
||||||
|
- Candidates already in cooldown are skipped entirely (no token consumed)
|
||||||
|
- Candidates that are available get throttled to the configured RPM
|
||||||
|
|
||||||
|
The same check applies in `ExecuteImage`.
|
||||||
|
|
||||||
|
### Thread safety
|
||||||
|
|
||||||
|
`RateLimiterRegistry` is safe for concurrent use. The per-limiter token bucket uses a fine-grained mutex so concurrent goroutines each acquire their own token independently.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Set `rpm` on any model in `model_list`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
- model_name: gpt-4o-free
|
||||||
|
model: openai/gpt-4o
|
||||||
|
api_base: https://api.openai.com/v1
|
||||||
|
rpm: 3 # max 3 requests per minute
|
||||||
|
api_keys:
|
||||||
|
- sk-...
|
||||||
|
|
||||||
|
- model_name: claude-haiku
|
||||||
|
model: anthropic/claude-haiku-4-5
|
||||||
|
rpm: 60 # 60 rpm (Anthropic free tier)
|
||||||
|
api_keys:
|
||||||
|
- sk-ant-...
|
||||||
|
|
||||||
|
- model_name: local-llm
|
||||||
|
model: openai/llama3
|
||||||
|
api_base: http://localhost:11434/v1
|
||||||
|
# no rpm → unrestricted
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `rpm` | `int` | `0` | Requests per minute. `0` means no limit. |
|
||||||
|
|
||||||
|
### Interaction with fallbacks
|
||||||
|
|
||||||
|
When a model has fallbacks configured, each candidate is rate-limited **independently**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
- model_name: gpt4-with-fallback
|
||||||
|
model: openai/gpt-4o
|
||||||
|
rpm: 5
|
||||||
|
fallbacks:
|
||||||
|
- gpt-4o-mini # must also be in model_list; its own rpm applies
|
||||||
|
```
|
||||||
|
|
||||||
|
If the current candidate's bucket is empty and there are more candidates available, PicoClaw skips the locally saturated candidate and tries the next fallback immediately. Only the last remaining candidate waits for a token to refill. If the context deadline is hit while waiting on that last candidate, the wait error propagates.
|
||||||
|
|
||||||
|
For `model_list` aliases that resolve to the same underlying provider/model, rate limiting is keyed by the stable config identity (for example `model_name`) rather than the resolved runtime model string. This preserves distinct RPM settings for multi-key and alias-based configurations.
|
||||||
|
|
||||||
|
### Burst behaviour
|
||||||
|
|
||||||
|
The bucket starts **full** (burst = RPM). For `rpm: 3`, the first 3 requests fire instantly; subsequent requests are spaced ~20 s apart.
|
||||||
|
|
||||||
|
To reduce burstiness for strict APIs, set a lower `rpm` and rely on the steady-state refill.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
| File | What |
|
||||||
|
|---|---|
|
||||||
|
| `pkg/providers/ratelimiter.go` | `RateLimiter` (token bucket) + `RateLimiterRegistry` |
|
||||||
|
| `pkg/providers/ratelimiter_test.go` | Unit tests for limiter and registry |
|
||||||
|
| `pkg/providers/fallback.go` | `FallbackCandidate.RPM` field; `FallbackChain.rl`; `Wait()` call in `Execute`/`ExecuteImage` |
|
||||||
|
| `pkg/agent/model_resolution.go` | Resolves candidates from `model_list`, preserving stable config identity and propagating `RPM` into `FallbackCandidate` |
|
||||||
|
| `pkg/agent/loop.go` | Build `RateLimiterRegistry`, register all agents' candidates, pass to `NewFallbackChain` |
|
||||||
|
|
@ -401,7 +401,7 @@ The pattern is: `PICOCLAW_<SECTION>_<KEY>_<FIELD>` with underscores separating p
|
||||||
3. **Set file permissions**: `chmod 600 ~/.picoclaw/.security.yml`
|
3. **Set file permissions**: `chmod 600 ~/.picoclaw/.security.yml`
|
||||||
4. **Use different keys** for different environments (dev, staging, production)
|
4. **Use different keys** for different environments (dev, staging, production)
|
||||||
5. **Rotate keys regularly** and update `.security.yml`
|
5. **Rotate keys regularly** and update `.security.yml`
|
||||||
6. **Backup securely**: Encrypt backups containing `.security.yml`
|
6. **Backup securely**: Encrypt backups containing `.security.yml`. Note that config migrations automatically create date-stamped backups (e.g., `config.json.20260330.bak` and `.security.yml.20260330.bak`)
|
||||||
7. **Review access**: Ensure only authorized users have read access to the file
|
7. **Review access**: Ensure only authorized users have read access to the file
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
@ -444,7 +444,7 @@ Returns the path to `.security.yml` relative to the config file.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/picoclaw-workspace",
|
"workspace": "~/picoclaw-workspace",
|
||||||
|
|
@ -557,6 +557,8 @@ go test ./pkg/config -run TestSecurityConfig
|
||||||
|
|
||||||
### Step 1: Backup your config
|
### Step 1: Backup your config
|
||||||
|
|
||||||
|
The system automatically creates a date-stamped backup before saving a migrated config (e.g., `config.json.20260330.bak` and `.security.yml.20260330.bak`). If you prefer a manual backup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp ~/.picoclaw/config.json ~/.picoclaw/config.json.backup
|
cp ~/.picoclaw/config.json ~/.picoclaw/config.json.backup
|
||||||
```
|
```
|
||||||
|
|
@ -597,9 +599,11 @@ Test your models and channels to ensure everything works correctly.
|
||||||
|
|
||||||
### Step 8: Clean up (optional)
|
### Step 8: Clean up (optional)
|
||||||
|
|
||||||
If everything works, you can delete the backup:
|
If everything works, you can delete the backups:
|
||||||
```bash
|
```bash
|
||||||
rm ~/.picoclaw/config.json.backup
|
rm ~/.picoclaw/config.json.backup
|
||||||
|
# Also remove auto-generated date-stamped backups if desired:
|
||||||
|
rm ~/.picoclaw/config.json.20*.bak ~/.picoclaw/.security.yml.20*.bak
|
||||||
```
|
```
|
||||||
|
|
||||||
## Advanced: Encrypted API Keys
|
## Advanced: Encrypted API Keys
|
||||||
|
|
|
||||||
|
|
@ -248,8 +248,11 @@ The cron tool is used for scheduling periodic tasks.
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|------------------------|------|---------|------------------------------------------------|
|
|------------------------|------|---------|------------------------------------------------|
|
||||||
|
| `enabled` | bool | true | Register the agent-facing cron tool |
|
||||||
|
| `allow_command` | bool | true | Allow command jobs without extra confirmation |
|
||||||
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
||||||
| `allow_command` | bool | false | Allow cron tasks to execute shell commands |
|
|
||||||
|
For schedule types, execution modes (`deliver`, agent turn, and command jobs), persistence, and the current command-security gates, see [Scheduled Tasks and Cron Jobs](cron.md).
|
||||||
|
|
||||||
## MCP Tool
|
## MCP Tool
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Mức Log của Gateway
|
||||||
|
|
||||||
|
`gateway.log_level` kiểm soát mức độ chi tiết của log Gateway, có thể cấu hình trong `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gateway": {
|
||||||
|
"log_level": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Giá trị mặc định là `warn`. Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`.
|
||||||
|
|
||||||
|
Cũng có thể ghi đè bằng biến môi trường: `PICOCLAW_LOG_LEVEL=info`
|
||||||
|
|
||||||
### Bố Cục Workspace
|
### Bố Cục Workspace
|
||||||
|
|
||||||
PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`):
|
PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`):
|
||||||
|
|
@ -319,15 +335,15 @@ Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự đ
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" },
|
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] },
|
||||||
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" }
|
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Di Chuyển Từ Cấu Hình `providers` Cũ
|
#### Di Chuyển Từ Cấu Hình `providers` Cũ
|
||||||
|
|
||||||
Cấu hình `providers` cũ đã **bị deprecated** nhưng vẫn được hỗ trợ. Xem [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
|
Cấu hình `providers` cũ đã **bị deprecated** và đã được loại bỏ trong V2. Các cấu hình V0/V1 hiện có sẽ được tự động migrate. Xem [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
|
||||||
|
|
||||||
### Kiến Trúc Provider
|
### Kiến Trúc Provider
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,19 +92,19 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key",
|
"api_keys": ["sk-your-api-key"],
|
||||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "your-api-key",
|
"api_keys": ["your-api-key"],
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "your-anthropic-key"
|
"api_keys": ["your-anthropic-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
|
||||||
|
|
@ -73,22 +73,22 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key"
|
"api_keys": ["sk-your-api-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_keys": ["sk-your-openai-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key"
|
"api_keys": ["sk-ant-your-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-zhipu-key"
|
"api_keys": ["your-zhipu-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
@ -99,6 +99,24 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Các trường entry `model_list`
|
||||||
|
|
||||||
|
| Trường | Kiểu | Bắt buộc | Mô tả |
|
||||||
|
|--------|------|----------|------|
|
||||||
|
| `model_name` | string | Có | Tên duy nhất để tham chiếu model này trong cấu hình agent |
|
||||||
|
| `model` | string | Có | Định danh nhà cung cấp/model (ví dụ: `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
|
||||||
|
| `api_keys` | string[] | Có* | Khóa API xác thực. Nhiều khóa cho phép xoay vòng theo yêu cầu. Không cần thiết cho provider nội bộ (Ollama, LM Studio, VLLM) |
|
||||||
|
| `api_base` | string | Không | Ghi đè URL endpoint API mặc định |
|
||||||
|
| `proxy` | string | Không | URL proxy HTTP cho entry model này |
|
||||||
|
| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Anthropic và Azure) |
|
||||||
|
| `request_timeout` | int | Không | Timeout yêu cầu tính bằng giây (mặc định khác nhau tùy provider) |
|
||||||
|
| `max_tokens_field` | string | Không | Ghi đè tên trường max tokens trong request body (ví dụ: `max_completion_tokens` cho model o1) |
|
||||||
|
| `thinking_level` | string | Không | Mức độ tư duy mở rộng: `off`, `low`, `medium`, `high`, `xhigh` hoặc `adaptive` |
|
||||||
|
| `extra_body` | object | Không | Các trường bổ sung để chèn vào mỗi request body |
|
||||||
|
| `rpm` | int | Không | Giới hạn tốc độ yêu cầu mỗi phút |
|
||||||
|
| `fallbacks` | string[] | Không | Tên model dự phòng cho failover tự động |
|
||||||
|
| `enabled` | bool | Không | Kích hoạt hay vô hiệu hóa entry model này (mặc định: `true`) |
|
||||||
|
|
||||||
#### Ví Dụ Theo Vendor
|
#### Ví Dụ Theo Vendor
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
|
|
@ -107,7 +125,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -117,7 +135,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -127,7 +145,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-key"
|
"api_keys": ["your-key"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -137,7 +155,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
||||||
{
|
{
|
||||||
"model_name": "deepseek-chat",
|
"model_name": "deepseek-chat",
|
||||||
"model": "deepseek/deepseek-chat",
|
"model": "deepseek/deepseek-chat",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -147,7 +165,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key"
|
"api_keys": ["sk-ant-your-key"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -161,7 +179,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
||||||
{
|
{
|
||||||
"model_name": "claude-opus-4-6",
|
"model_name": "claude-opus-4-6",
|
||||||
"model": "anthropic-messages/claude-opus-4-6",
|
"model": "anthropic-messages/claude-opus-4-6",
|
||||||
"api_key": "sk-ant-your-key",
|
"api_keys": ["sk-ant-your-key"],
|
||||||
"api_base": "https://api.anthropic.com"
|
"api_base": "https://api.anthropic.com"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -189,7 +207,8 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-...",
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -201,7 +220,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
||||||
"model_name": "lite-gpt4",
|
"model_name": "lite-gpt4",
|
||||||
"model": "litellm/lite-gpt4",
|
"model": "litellm/lite-gpt4",
|
||||||
"api_base": "http://localhost:4000/v1",
|
"api_base": "http://localhost:4000/v1",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -218,13 +237,13 @@ Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự đ
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_keys": ["sk-key1"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_keys": ["sk-key2"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -232,7 +251,7 @@ Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự đ
|
||||||
|
|
||||||
#### Di Chuyển Từ Cấu Hình Legacy `providers`
|
#### Di Chuyển Từ Cấu Hình Legacy `providers`
|
||||||
|
|
||||||
Cấu hình `providers` cũ đã **ngừng hỗ trợ** nhưng vẫn được hỗ trợ để tương thích ngược.
|
Cấu hình `providers` cũ đã **bị deprecated** và đã được loại bỏ trong V2. Các cấu hình V0/V1 hiện có sẽ được tự động migrate.
|
||||||
|
|
||||||
**Cấu hình cũ (ngừng hỗ trợ):**
|
**Cấu hình cũ (ngừng hỗ trợ):**
|
||||||
|
|
||||||
|
|
@ -257,11 +276,12 @@ Cấu hình `providers` cũ đã **ngừng hỗ trợ** nhưng vẫn được h
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"version": 2,
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-key"
|
"api_keys": ["your-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
@ -282,7 +302,7 @@ PicoClaw định tuyến provider theo họ giao thức:
|
||||||
- Giao thức Anthropic: Hành vi API native của Claude.
|
- Giao thức Anthropic: Hành vi API native của Claude.
|
||||||
- Đường dẫn Codex/OAuth: Tuyến xác thực OAuth/token của OpenAI.
|
- Đường dẫn Codex/OAuth: Tuyến xác thực OAuth/token của OpenAI.
|
||||||
|
|
||||||
Điều này giữ runtime nhẹ trong khi làm cho backend tương thích OpenAI mới chủ yếu là thao tác cấu hình (`api_base` + `api_key`).
|
Điều này giữ runtime nhẹ trong khi làm cho backend tương thích OpenAI mới chủ yếu là thao tác cấu hình (`api_base` + `api_keys`).
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Zhipu</b></summary>
|
<summary><b>Zhipu</b></summary>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Gateway 日志等级
|
||||||
|
|
||||||
|
`gateway.log_level` 控制 Gateway 的日志详细程度,可在 `config.json` 中配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gateway": {
|
||||||
|
"log_level": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
默认值为 `warn`。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。
|
||||||
|
|
||||||
|
也可通过环境变量覆盖:`PICOCLAW_LOG_LEVEL=info`
|
||||||
|
|
||||||
### 工作区布局 (Workspace Layout)
|
### 工作区布局 (Workspace Layout)
|
||||||
|
|
||||||
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`):
|
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`):
|
||||||
|
|
@ -51,6 +67,18 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
||||||
|
|
||||||
> **提示:** 对 `AGENT.md`、`SOUL.md`、`USER.md` 和 `memory/MEMORY.md` 的修改会通过文件修改时间(mtime)在运行时自动检测。**无需重启 gateway**,Agent 将在下一次请求时自动加载最新内容。
|
> **提示:** 对 `AGENT.md`、`SOUL.md`、`USER.md` 和 `memory/MEMORY.md` 的修改会通过文件修改时间(mtime)在运行时自动检测。**无需重启 gateway**,Agent 将在下一次请求时自动加载最新内容。
|
||||||
|
|
||||||
|
### Web 启动器控制台
|
||||||
|
|
||||||
|
用 **picoclaw-launcher** 打开浏览器控制台前需要先登录。**访问口令**与 **会话签名密钥**默认在**每次启动时在内存中生成**(重启后随机口令会变)。若设置环境变量 **`PICOCLAW_LAUNCHER_TOKEN`**,则该进程使用固定口令(启动日志中不会打印具体口令值)。
|
||||||
|
|
||||||
|
**到哪里找口令**:**控制台模式**(`-console`)请看启动时的终端输出;**托盘 / GUI 模式**可使用托盘菜单中的「复制控制台口令」,并在 **`$PICOCLAW_HOME/logs/launcher.log`**(未设置 `PICOCLAW_HOME` 时一般为 `~/.picoclaw/logs/launcher.log`)中查看本次启动写入的随机口令。登录页在未登录时会根据当前运行方式展示提示(含日志文件绝对路径等;**接口与页面均不会返回口令本身**)。
|
||||||
|
|
||||||
|
- **配置文件**:与 `config.json` 同一目录(若设置了 `PICOCLAW_CONFIG`,则与它所指的文件同目录)。启动器专用文件名为 `launcher-config.json`。
|
||||||
|
- **登录与链接**:在登录页输入口令;自动打开浏览器时可在 URL 上使用 `?token=`。全站响应携带 **`Referrer-Policy: no-referrer`**,减轻 `token` 经 `Referer` 头泄露的风险。
|
||||||
|
- **退出登录**:应使用 **`POST /api/auth/logout`**,且请求头为 **`Content-Type: application/json`**(请求体可为 `{}`),勿使用可被第三方页面触发的 GET 链接登出。
|
||||||
|
- **暴力尝试**:`POST /api/auth/login` 对同一远程地址有 **每分钟尝试次数上限**(超限返回 HTTP 429)。
|
||||||
|
- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **7 天**有效,到期需重新用口令登录。
|
||||||
|
|
||||||
### 技能来源 (Skill Sources)
|
### 技能来源 (Skill Sources)
|
||||||
|
|
||||||
默认情况下,技能会按以下顺序加载:
|
默认情况下,技能会按以下顺序加载:
|
||||||
|
|
@ -337,6 +365,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) |
|
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) |
|
||||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) |
|
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) |
|
||||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key) |
|
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key) |
|
||||||
|
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) |
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) |
|
||||||
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key |
|
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||||
|
|
@ -358,22 +387,22 @@ Agent 读取 HEARTBEAT.md
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key"
|
"api_keys": ["sk-your-api-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_keys": ["sk-your-openai-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key"
|
"api_keys": ["sk-ant-your-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-zhipu-key"
|
"api_keys": ["your-zhipu-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
@ -393,7 +422,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -406,7 +435,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -419,7 +448,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-key"
|
"api_keys": ["your-key"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -432,7 +461,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
{
|
{
|
||||||
"model_name": "deepseek-chat",
|
"model_name": "deepseek-chat",
|
||||||
"model": "deepseek/deepseek-chat",
|
"model": "deepseek/deepseek-chat",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -445,7 +474,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key"
|
"api_keys": ["sk-ant-your-key"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -457,7 +486,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
{
|
{
|
||||||
"model_name": "claude-opus-4-6",
|
"model_name": "claude-opus-4-6",
|
||||||
"model": "anthropic-messages/claude-opus-4-6",
|
"model": "anthropic-messages/claude-opus-4-6",
|
||||||
"api_key": "sk-ant-your-key",
|
"api_keys": ["sk-ant-your-key"],
|
||||||
"api_base": "https://api.anthropic.com"
|
"api_base": "https://api.anthropic.com"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -478,6 +507,21 @@ Agent 读取 HEARTBEAT.md
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>LM Studio(本地)</b></summary>
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "lmstudio-local",
|
||||||
|
"model": "lmstudio/openai/gpt-oss-20b"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。
|
||||||
|
PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首个 `lmstudio/` 前缀,因此 `lmstudio/openai/gpt-oss-20b` 会发送 `openai/gpt-oss-20b`。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>自定义代理 / LiteLLM</b></summary>
|
<summary><b>自定义代理 / LiteLLM</b></summary>
|
||||||
|
|
||||||
|
|
@ -486,7 +530,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -505,13 +549,13 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_keys": ["sk-key1"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_keys": ["sk-key2"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -519,7 +563,7 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell
|
||||||
|
|
||||||
#### 从旧版 `providers` 配置迁移
|
#### 从旧版 `providers` 配置迁移
|
||||||
|
|
||||||
旧版 `providers` 配置**已废弃**,但仍向后兼容。完整迁移指南见 [docs/migration/model-list-migration.md](../migration/model-list-migration.md)。
|
旧版 `providers` 配置**已废弃**,V2 中已移除。现有 V0/V1 配置会自动迁移。完整迁移指南见 [docs/migration/model-list-migration.md](../migration/model-list-migration.md)。
|
||||||
|
|
||||||
### Provider 架构
|
### Provider 架构
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,10 @@ docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
在浏览器中打开 http://localhost:18800。Launcher 会自动管理 Gateway 进程。
|
在浏览器中打开 <http://localhost:18800>。Launcher 会自动管理 Gateway 进程。
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Web 控制台尚不支持身份验证。请勿将其暴露到公网。
|
> Web 控制台通过 dashboard 令牌鉴权(默认每次启动在内存中生成;可用 `PICOCLAW_LAUNCHER_TOKEN` 固定)。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。
|
||||||
|
|
||||||
### Agent 模式 (一次性运行)
|
### Agent 模式 (一次性运行)
|
||||||
|
|
||||||
|
|
@ -94,19 +94,19 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key",
|
"api_keys": ["sk-your-api-key"],
|
||||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "your-api-key",
|
"api_keys": ["your-api-key"],
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "your-anthropic-key"
|
"api_keys": ["your-anthropic-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
| `openrouter` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
| `openrouter` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `anthropic` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
|
| `openai` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
|
||||||
|
| `venice` | LLM (Venice AI 直连) | [venice.ai](https://venice.ai) |
|
||||||
| `deepseek` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
|
| `deepseek` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||||
| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||||
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
|
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||||
|
|
@ -44,6 +45,7 @@
|
||||||
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
|
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
|
||||||
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- |
|
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- |
|
||||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
|
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
|
||||||
|
| **Venice AI** | `venice/` | `https://api.venice.ai/api/v1` | OpenAI | [获取密钥](https://venice.ai) |
|
||||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
|
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
|
||||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |
|
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |
|
||||||
|
|
@ -53,6 +55,7 @@
|
||||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
|
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
|
||||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
|
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
|
||||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
|
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
|
||||||
|
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) |
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
||||||
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 |
|
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||||
|
|
@ -75,22 +78,22 @@
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key"
|
"api_keys": ["sk-your-api-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_keys": ["sk-your-openai-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key"
|
"api_keys": ["sk-ant-your-key"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-zhipu-key"
|
"api_keys": ["your-zhipu-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
@ -101,6 +104,24 @@
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `model_list` 条目字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `model_name` | string | 是 | 在 agent 配置中引用此模型的唯一名称 |
|
||||||
|
| `model` | string | 是 | 厂商/模型标识符(如 `openai/gpt-5.4`、`azure/gpt-5.4`、`anthropic/claude-sonnet-4.6`) |
|
||||||
|
| `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 provider(Ollama、LM Studio、VLLM)不需要 |
|
||||||
|
| `api_base` | string | 否 | 覆盖默认的 API 端点 URL |
|
||||||
|
| `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL |
|
||||||
|
| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Anthropic 和 Azure provider) |
|
||||||
|
| `request_timeout` | int | 否 | 请求超时时间(秒),默认值因 provider 而异 |
|
||||||
|
| `max_tokens_field` | string | 否 | 覆盖请求体中 max tokens 的字段名(如 o1 模型使用 `max_completion_tokens`) |
|
||||||
|
| `thinking_level` | string | 否 | 扩展思考级别:`off`、`low`、`medium`、`high`、`xhigh` 或 `adaptive` |
|
||||||
|
| `extra_body` | object | 否 | 注入到每个请求体中的额外字段 |
|
||||||
|
| `rpm` | int | 否 | 每分钟请求速率限制 |
|
||||||
|
| `fallbacks` | string[] | 否 | 自动故障转移的备用模型名称 |
|
||||||
|
| `enabled` | bool | 否 | 是否启用此模型条目(默认:`true`) |
|
||||||
|
|
||||||
#### 语音转录
|
#### 语音转录
|
||||||
|
|
||||||
你可以通过 `voice.model_name` 为语音转录指定一个专用模型。这样可以直接复用已经配置好的、支持音频输入的多模态 provider,而不必只依赖 Groq。
|
你可以通过 `voice.model_name` 为语音转录指定一个专用模型。这样可以直接复用已经配置好的、支持音频输入的多模态 provider,而不必只依赖 Groq。
|
||||||
|
|
@ -113,7 +134,7 @@
|
||||||
{
|
{
|
||||||
"model_name": "voice-gemini",
|
"model_name": "voice-gemini",
|
||||||
"model": "gemini/gemini-2.5-flash",
|
"model": "gemini/gemini-2.5-flash",
|
||||||
"api_key": "your-gemini-key"
|
"api_keys": ["your-gemini-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"voice": {
|
"voice": {
|
||||||
|
|
@ -136,7 +157,7 @@
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -146,7 +167,7 @@
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -156,7 +177,7 @@
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-key"
|
"api_keys": ["your-key"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -166,7 +187,7 @@
|
||||||
{
|
{
|
||||||
"model_name": "deepseek-chat",
|
"model_name": "deepseek-chat",
|
||||||
"model": "deepseek/deepseek-chat",
|
"model": "deepseek/deepseek-chat",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -190,7 +211,7 @@
|
||||||
{
|
{
|
||||||
"model_name": "claude-opus-4-6",
|
"model_name": "claude-opus-4-6",
|
||||||
"model": "anthropic-messages/claude-opus-4-6",
|
"model": "anthropic-messages/claude-opus-4-6",
|
||||||
"api_key": "sk-ant-your-key",
|
"api_keys": ["sk-ant-your-key"],
|
||||||
"api_base": "https://api.anthropic.com"
|
"api_base": "https://api.anthropic.com"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -211,6 +232,18 @@
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**LM Studio(本地)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "lmstudio-local",
|
||||||
|
"model": "lmstudio/openai/gpt-oss-20b"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。
|
||||||
|
PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首个 `lmstudio/` 前缀,因此 `lmstudio/openai/gpt-oss-20b` 会发送 `openai/gpt-oss-20b`。
|
||||||
|
|
||||||
**自定义代理/API**
|
**自定义代理/API**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -218,7 +251,8 @@
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-...",
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -230,7 +264,7 @@
|
||||||
"model_name": "lite-gpt4",
|
"model_name": "lite-gpt4",
|
||||||
"model": "litellm/lite-gpt4",
|
"model": "litellm/lite-gpt4",
|
||||||
"api_base": "http://localhost:4000/v1",
|
"api_base": "http://localhost:4000/v1",
|
||||||
"api_key": "sk-..."
|
"api_keys": ["sk-..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -247,13 +281,13 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_keys": ["sk-key1"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_keys": ["sk-key2"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -272,17 +306,17 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l
|
||||||
"model_name": "qwen-main",
|
"model_name": "qwen-main",
|
||||||
"model": "openai/qwen3.5:cloud",
|
"model": "openai/qwen3.5:cloud",
|
||||||
"api_base": "https://api.example.com/v1",
|
"api_base": "https://api.example.com/v1",
|
||||||
"api_key": "sk-main"
|
"api_keys": ["sk-main"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "deepseek-backup",
|
"model_name": "deepseek-backup",
|
||||||
"model": "deepseek/deepseek-chat",
|
"model": "deepseek/deepseek-chat",
|
||||||
"api_key": "sk-backup-1"
|
"api_keys": ["sk-backup-1"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gemini-backup",
|
"model_name": "gemini-backup",
|
||||||
"model": "gemini/gemini-2.5-flash",
|
"model": "gemini/gemini-2.5-flash",
|
||||||
"api_key": "sk-backup-2"
|
"api_keys": ["sk-backup-2"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
@ -300,7 +334,7 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l
|
||||||
|
|
||||||
#### 从旧的 `providers` 配置迁移
|
#### 从旧的 `providers` 配置迁移
|
||||||
|
|
||||||
旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。
|
旧的 `providers` 配置格式**已弃用**,V2 中已移除。现有 V0/V1 配置会自动迁移。
|
||||||
|
|
||||||
**旧配置(已弃用):**
|
**旧配置(已弃用):**
|
||||||
|
|
||||||
|
|
@ -325,11 +359,12 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"version": 2,
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7",
|
||||||
"api_key": "your-key"
|
"api_keys": ["your-key"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
|
||||||
33
go.mod
33
go.mod
|
|
@ -7,9 +7,10 @@ require (
|
||||||
github.com/BurntSushi/toml v1.6.0
|
github.com/BurntSushi/toml v1.6.0
|
||||||
github.com/adhocore/gronx v1.19.6
|
github.com/adhocore/gronx v1.19.6
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.4
|
github.com/atotto/clipboard v0.1.4
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.12
|
github.com/aws/aws-sdk-go-v2/config v1.32.12
|
||||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2
|
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.4.0
|
github.com/caarlos0/env/v11 v11.4.0
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
|
|
@ -22,10 +23,13 @@ require (
|
||||||
github.com/h2non/filetype v1.1.3
|
github.com/h2non/filetype v1.1.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
|
github.com/minio/selfupdate v0.6.0
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1
|
github.com/modelcontextprotocol/go-sdk v1.4.1
|
||||||
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
|
||||||
|
|
@ -36,21 +40,22 @@ require (
|
||||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/term v0.41.0
|
golang.org/x/term v0.41.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.15.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
maunium.net/go/mautrix v0.26.4
|
maunium.net/go/mautrix v0.26.4
|
||||||
modernc.org/sqlite v1.46.1
|
modernc.org/sqlite v1.47.0
|
||||||
rsc.io/qr v0.2.0
|
rsc.io/qr v0.2.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
aead.dev/minisign v0.2.0 // indirect
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
||||||
|
|
@ -60,11 +65,14 @@ 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
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
github.com/gdamore/encoding v1.0.1 // indirect
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
|
@ -73,6 +81,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
|
||||||
|
|
@ -81,9 +90,13 @@ require (
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||||
go.mau.fi/libsignal v0.2.1 // indirect
|
go.mau.fi/libsignal v0.2.1 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.70.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
@ -94,7 +107,7 @@ require (
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/github/copilot-sdk/go v0.1.32
|
github.com/github/copilot-sdk/go v0.2.0
|
||||||
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
|
|
@ -116,3 +129,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
|
||||||
|
|
|
||||||
91
go.sum
91
go.sum
|
|
@ -1,3 +1,5 @@
|
||||||
|
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
|
||||||
|
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
|
||||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
|
|
@ -17,24 +19,26 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY=
|
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
|
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
|
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2 h1:x0eGAWpd1B5I/vMtrB4Q4Zuc3CXWI8wjHfPPqBSrKmM=
|
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
|
||||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2/go.mod h1:V9oTWSDC2MtS1DR71hbNET/bZ8psQp022amEBe1grJc=
|
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
|
||||||
|
|
@ -51,8 +55,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=
|
||||||
|
|
@ -63,6 +65,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=
|
||||||
|
|
@ -92,8 +96,13 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh
|
||||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||||
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||||
github.com/github/copilot-sdk/go v0.1.32 h1:wc9SFWwxXhJts6vyzzboPLJqcEJGnHE8rMCAY1RrUgo=
|
github.com/github/copilot-sdk/go v0.2.0 h1:RnrIIirmtp4wGgqSQFJ2k9phbeveIxOtYZqDogoNEa0=
|
||||||
github.com/github/copilot-sdk/go v0.1.32/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts=
|
github.com/github/copilot-sdk/go v0.2.0/go.mod h1:uGWkjVYcp2DV9DgtqYihh5tEoJjNqxIFaUNnrwY4FxM=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||||
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||||
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
||||||
|
|
@ -155,8 +164,9 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
|
@ -176,6 +186,8 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
|
||||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||||
|
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
||||||
|
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
|
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
|
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
|
||||||
github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=
|
github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=
|
||||||
|
|
@ -196,6 +208,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=
|
||||||
|
|
@ -207,8 +225,9 @@ github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoX
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
|
|
@ -264,6 +283,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=
|
||||||
|
|
@ -275,6 +296,14 @@ go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg=
|
||||||
go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE=
|
go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE=
|
||||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
|
||||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||||
|
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||||
|
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||||
|
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||||
|
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||||
|
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
|
@ -283,8 +312,10 @@ 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-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
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.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
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=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
|
|
@ -305,6 +336,7 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
|
@ -327,11 +359,13 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|
@ -345,6 +379,7 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
|
@ -361,8 +396,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
|
@ -405,18 +440,18 @@ maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs=
|
||||||
maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M=
|
maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M=
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
|
@ -425,8 +460,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
|
@ -59,14 +58,7 @@ func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getGlobalConfigDir() string {
|
func getGlobalConfigDir() string {
|
||||||
if home := os.Getenv(config.EnvHome); home != "" {
|
return config.GetHome()
|
||||||
return home
|
|
||||||
}
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return filepath.Join(home, pkg.DefaultPicoClawHome)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewContextBuilder(workspace string) *ContextBuilder {
|
func NewContextBuilder(workspace string) *ContextBuilder {
|
||||||
|
|
|
||||||
|
|
@ -90,14 +90,29 @@ func findSafeBoundary(history []providers.Message, targetIndex int) int {
|
||||||
// including Content, ReasoningContent, ToolCalls arguments, ToolCallID
|
// including Content, ReasoningContent, ToolCalls arguments, ToolCallID
|
||||||
// metadata, and Media items. Uses a heuristic of 2.5 characters per token.
|
// metadata, and Media items. Uses a heuristic of 2.5 characters per token.
|
||||||
func estimateMessageTokens(msg providers.Message) int {
|
func estimateMessageTokens(msg providers.Message) int {
|
||||||
chars := utf8.RuneCountInString(msg.Content)
|
contentChars := utf8.RuneCountInString(msg.Content)
|
||||||
|
|
||||||
// ReasoningContent (extended thinking / chain-of-thought) can be
|
// SystemParts are structured system blocks used for cache-aware adapters.
|
||||||
// substantial and is stored in session history via AddFullMessage.
|
// They carry the same content as Content, but in multiple blocks.
|
||||||
if msg.ReasoningContent != "" {
|
// We estimate them as an alternative representation, not additive.
|
||||||
chars += utf8.RuneCountInString(msg.ReasoningContent)
|
systemPartsChars := 0
|
||||||
|
if len(msg.SystemParts) > 0 {
|
||||||
|
for _, part := range msg.SystemParts {
|
||||||
|
systemPartsChars += utf8.RuneCountInString(part.Text)
|
||||||
|
}
|
||||||
|
// Per-part overhead for JSON structure (type, text, cache_control).
|
||||||
|
const perPartOverhead = 20
|
||||||
|
systemPartsChars += len(msg.SystemParts) * perPartOverhead
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the larger of the two representations to stay conservative.
|
||||||
|
chars := contentChars
|
||||||
|
if systemPartsChars > chars {
|
||||||
|
chars = systemPartsChars
|
||||||
|
}
|
||||||
|
|
||||||
|
chars += utf8.RuneCountInString(msg.ReasoningContent)
|
||||||
|
|
||||||
for _, tc := range msg.ToolCalls {
|
for _, tc := range msg.ToolCalls {
|
||||||
chars += len(tc.ID) + len(tc.Type)
|
chars += len(tc.ID) + len(tc.Type)
|
||||||
if tc.Function != nil {
|
if tc.Function != nil {
|
||||||
|
|
|
||||||
|
|
@ -529,6 +529,26 @@ func TestEstimateMessageTokens_MediaItems(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEstimateMessageTokens_SystemParts(t *testing.T) {
|
||||||
|
plain := providers.Message{Role: "system", Content: "instructions"}
|
||||||
|
withParts := providers.Message{
|
||||||
|
Role: "system",
|
||||||
|
Content: "instructions",
|
||||||
|
SystemParts: []providers.ContentBlock{
|
||||||
|
{Type: "text", Text: "some more system context"},
|
||||||
|
{Type: "text", Text: "even more cached blocks"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
plainTokens := estimateMessageTokens(plain)
|
||||||
|
partsTokens := estimateMessageTokens(withParts)
|
||||||
|
|
||||||
|
if partsTokens <= plainTokens {
|
||||||
|
t.Errorf("system message with SystemParts (%d) should exceed plain message (%d)",
|
||||||
|
partsTokens, plainTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- estimateToolDefsTokens tests ---
|
// --- estimateToolDefsTokens tests ---
|
||||||
|
|
||||||
func TestEstimateToolDefsTokens(t *testing.T) {
|
func TestEstimateToolDefsTokens(t *testing.T) {
|
||||||
|
|
|
||||||
379
pkg/agent/context_legacy.go
Normal file
379
pkg/agent/context_legacy.go
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// legacyContextManager wraps the existing summarization/compression logic
|
||||||
|
// as a ContextManager implementation. It is the default when no other
|
||||||
|
// ContextManager is configured.
|
||||||
|
type legacyContextManager struct {
|
||||||
|
al *AgentLoop
|
||||||
|
summarizing sync.Map // dedup for async Compact (post-turn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *legacyContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) {
|
||||||
|
// Legacy: read history from session, return as-is.
|
||||||
|
// Budget enforcement happens in BuildMessages caller via
|
||||||
|
// isOverContextBudget + forceCompression.
|
||||||
|
agent := m.al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return &AssembleResponse{}, nil
|
||||||
|
}
|
||||||
|
history := agent.Sessions.GetHistory(req.SessionKey)
|
||||||
|
summary := agent.Sessions.GetSummary(req.SessionKey)
|
||||||
|
return &AssembleResponse{
|
||||||
|
History: history,
|
||||||
|
Summary: summary,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *legacyContextManager) Compact(_ context.Context, req *CompactRequest) error {
|
||||||
|
switch req.Reason {
|
||||||
|
case ContextCompressReasonProactive, ContextCompressReasonRetry:
|
||||||
|
// Sync emergency compression — budget exceeded.
|
||||||
|
if result, ok := m.forceCompression(req.SessionKey); ok {
|
||||||
|
m.al.emitEvent(
|
||||||
|
EventKindContextCompress,
|
||||||
|
m.al.newTurnEventScope("", req.SessionKey).meta(0, "forceCompression", "turn.context.compress"),
|
||||||
|
ContextCompressPayload{
|
||||||
|
Reason: req.Reason,
|
||||||
|
DroppedMessages: result.DroppedMessages,
|
||||||
|
RemainingMessages: result.RemainingMessages,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case ContextCompressReasonSummarize:
|
||||||
|
m.maybeSummarize(req.SessionKey)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *legacyContextManager) Ingest(_ context.Context, _ *IngestRequest) error {
|
||||||
|
// Legacy: no-op. Messages are persisted by Sessions JSONL.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
||||||
|
// It runs asynchronously in a goroutine.
|
||||||
|
func (m *legacyContextManager) maybeSummarize(sessionKey string) {
|
||||||
|
agent := m.al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newHistory := agent.Sessions.GetHistory(sessionKey)
|
||||||
|
tokenEstimate := m.estimateTokens(newHistory)
|
||||||
|
threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100
|
||||||
|
|
||||||
|
if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold {
|
||||||
|
summarizeKey := agent.ID + ":" + sessionKey
|
||||||
|
if _, loading := m.summarizing.LoadOrStore(summarizeKey, true); !loading {
|
||||||
|
go func() {
|
||||||
|
defer m.summarizing.Delete(summarizeKey)
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.WarnCF("agent", "Summarization panic recovered", map[string]any{
|
||||||
|
"session_key": sessionKey,
|
||||||
|
"panic": r,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
logger.Debug("Memory threshold reached. Optimizing conversation history...")
|
||||||
|
m.summarizeSession(agent, sessionKey)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type compressionResult struct {
|
||||||
|
DroppedMessages int
|
||||||
|
RemainingMessages int
|
||||||
|
}
|
||||||
|
|
||||||
|
// forceCompression aggressively reduces context when the limit is hit.
|
||||||
|
// It drops the oldest ~50% of Turns (a Turn is a complete user→LLM→response
|
||||||
|
// cycle, as defined in #1316), so tool-call sequences are never split.
|
||||||
|
func (m *legacyContextManager) forceCompression(sessionKey string) (compressionResult, bool) {
|
||||||
|
agent := m.al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return compressionResult{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
history := agent.Sessions.GetHistory(sessionKey)
|
||||||
|
if len(history) <= 2 {
|
||||||
|
return compressionResult{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
turns := parseTurnBoundaries(history)
|
||||||
|
var mid int
|
||||||
|
if len(turns) >= 2 {
|
||||||
|
mid = turns[len(turns)/2]
|
||||||
|
} else {
|
||||||
|
mid = findSafeBoundary(history, len(history)/2)
|
||||||
|
}
|
||||||
|
var keptHistory []providers.Message
|
||||||
|
if mid <= 0 {
|
||||||
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
|
if history[i].Role == "user" {
|
||||||
|
keptHistory = []providers.Message{history[i]}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
keptHistory = history[mid:]
|
||||||
|
}
|
||||||
|
|
||||||
|
droppedCount := len(history) - len(keptHistory)
|
||||||
|
|
||||||
|
existingSummary := agent.Sessions.GetSummary(sessionKey)
|
||||||
|
compressionNote := fmt.Sprintf(
|
||||||
|
"[Emergency compression dropped %d oldest messages due to context limit]",
|
||||||
|
droppedCount,
|
||||||
|
)
|
||||||
|
if existingSummary != "" {
|
||||||
|
compressionNote = existingSummary + "\n\n" + compressionNote
|
||||||
|
}
|
||||||
|
agent.Sessions.SetSummary(sessionKey, compressionNote)
|
||||||
|
|
||||||
|
agent.Sessions.SetHistory(sessionKey, keptHistory)
|
||||||
|
agent.Sessions.Save(sessionKey)
|
||||||
|
|
||||||
|
logger.WarnCF("agent", "Forced compression executed", map[string]any{
|
||||||
|
"session_key": sessionKey,
|
||||||
|
"dropped_msgs": droppedCount,
|
||||||
|
"new_count": len(keptHistory),
|
||||||
|
})
|
||||||
|
|
||||||
|
return compressionResult{
|
||||||
|
DroppedMessages: droppedCount,
|
||||||
|
RemainingMessages: len(keptHistory),
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
history := agent.Sessions.GetHistory(sessionKey)
|
||||||
|
summary := agent.Sessions.GetSummary(sessionKey)
|
||||||
|
|
||||||
|
if len(history) <= 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
safeCut := findSafeBoundary(history, len(history)-4)
|
||||||
|
if safeCut <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keepCount := len(history) - safeCut
|
||||||
|
toSummarize := history[:safeCut]
|
||||||
|
|
||||||
|
maxMessageTokens := agent.ContextWindow / 2
|
||||||
|
validMessages := make([]providers.Message, 0)
|
||||||
|
omitted := false
|
||||||
|
|
||||||
|
for _, msg := range toSummarize {
|
||||||
|
if msg.Role != "user" && msg.Role != "assistant" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msgTokens := len(msg.Content) / 2
|
||||||
|
if msgTokens > maxMessageTokens {
|
||||||
|
omitted = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
validMessages = append(validMessages, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(validMessages) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxSummarizationMessages = 10
|
||||||
|
llmMaxRetries = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
var finalSummary string
|
||||||
|
if len(validMessages) > maxSummarizationMessages {
|
||||||
|
mid := len(validMessages) / 2
|
||||||
|
mid = m.findNearestUserMessage(validMessages, mid)
|
||||||
|
|
||||||
|
part1 := validMessages[:mid]
|
||||||
|
part2 := validMessages[mid:]
|
||||||
|
|
||||||
|
s1, _ := m.summarizeBatch(ctx, agent, part1, "")
|
||||||
|
s2, _ := m.summarizeBatch(ctx, agent, part2, "")
|
||||||
|
|
||||||
|
mergePrompt := fmt.Sprintf(
|
||||||
|
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
|
||||||
|
s1, s2,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp, err := m.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries)
|
||||||
|
if err == nil && resp.Content != "" {
|
||||||
|
finalSummary = resp.Content
|
||||||
|
} else {
|
||||||
|
finalSummary = s1 + " " + s2
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
finalSummary, _ = m.summarizeBatch(ctx, agent, validMessages, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if omitted && finalSummary != "" {
|
||||||
|
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if finalSummary != "" {
|
||||||
|
agent.Sessions.SetSummary(sessionKey, finalSummary)
|
||||||
|
agent.Sessions.TruncateHistory(sessionKey, keepCount)
|
||||||
|
agent.Sessions.Save(sessionKey)
|
||||||
|
m.al.emitEvent(
|
||||||
|
EventKindSessionSummarize,
|
||||||
|
m.al.newTurnEventScope(agent.ID, sessionKey).meta(0, "summarizeSession", "turn.session.summarize"),
|
||||||
|
SessionSummarizePayload{
|
||||||
|
SummarizedMessages: len(validMessages),
|
||||||
|
KeptMessages: keepCount,
|
||||||
|
SummaryLen: len(finalSummary),
|
||||||
|
OmittedOversized: omitted,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *legacyContextManager) findNearestUserMessage(messages []providers.Message, mid int) int {
|
||||||
|
originalMid := mid
|
||||||
|
|
||||||
|
for mid > 0 && messages[mid].Role != "user" {
|
||||||
|
mid--
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages[mid].Role == "user" {
|
||||||
|
return mid
|
||||||
|
}
|
||||||
|
|
||||||
|
mid = originalMid
|
||||||
|
for mid < len(messages) && messages[mid].Role != "user" {
|
||||||
|
mid++
|
||||||
|
}
|
||||||
|
|
||||||
|
if mid < len(messages) {
|
||||||
|
return mid
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalMid
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *legacyContextManager) retryLLMCall(
|
||||||
|
ctx context.Context,
|
||||||
|
agent *AgentInstance,
|
||||||
|
prompt string,
|
||||||
|
maxRetries int,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
const llmTemperature = 0.3
|
||||||
|
|
||||||
|
var resp *providers.LLMResponse
|
||||||
|
var err error
|
||||||
|
|
||||||
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
|
m.al.activeRequests.Add(1)
|
||||||
|
resp, err = func() (*providers.LLMResponse, error) {
|
||||||
|
defer m.al.activeRequests.Done()
|
||||||
|
return agent.Provider.Chat(
|
||||||
|
ctx,
|
||||||
|
[]providers.Message{{Role: "user", Content: prompt}},
|
||||||
|
nil,
|
||||||
|
agent.Model,
|
||||||
|
map[string]any{
|
||||||
|
"max_tokens": agent.MaxTokens,
|
||||||
|
"temperature": llmTemperature,
|
||||||
|
"prompt_cache_key": agent.ID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err == nil && resp != nil && resp.Content != "" {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if attempt < maxRetries-1 {
|
||||||
|
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *legacyContextManager) summarizeBatch(
|
||||||
|
ctx context.Context,
|
||||||
|
agent *AgentInstance,
|
||||||
|
batch []providers.Message,
|
||||||
|
existingSummary string,
|
||||||
|
) (string, error) {
|
||||||
|
const (
|
||||||
|
llmMaxRetries = 3
|
||||||
|
fallbackMinContentLength = 200
|
||||||
|
fallbackMaxContentPercent = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n")
|
||||||
|
if existingSummary != "" {
|
||||||
|
sb.WriteString("Existing context: ")
|
||||||
|
sb.WriteString(existingSummary)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("\nCONVERSATION:\n")
|
||||||
|
for _, msg := range batch {
|
||||||
|
fmt.Fprintf(&sb, "%s: %s\n", msg.Role, msg.Content)
|
||||||
|
}
|
||||||
|
prompt := sb.String()
|
||||||
|
|
||||||
|
response, err := m.retryLLMCall(ctx, agent, prompt, llmMaxRetries)
|
||||||
|
if err == nil && response.Content != "" {
|
||||||
|
return strings.TrimSpace(response.Content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var fallback strings.Builder
|
||||||
|
fallback.WriteString("Conversation summary: ")
|
||||||
|
for i, msg := range batch {
|
||||||
|
if i > 0 {
|
||||||
|
fallback.WriteString(" | ")
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(msg.Content)
|
||||||
|
runes := []rune(content)
|
||||||
|
if len(runes) == 0 {
|
||||||
|
fallback.WriteString(fmt.Sprintf("%s: ", msg.Role))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
keepLength := len(runes) * fallbackMaxContentPercent / 100
|
||||||
|
if keepLength < fallbackMinContentLength {
|
||||||
|
keepLength = fallbackMinContentLength
|
||||||
|
}
|
||||||
|
if keepLength > len(runes) {
|
||||||
|
keepLength = len(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
content = string(runes[:keepLength])
|
||||||
|
if keepLength < len(runes) {
|
||||||
|
content += "..."
|
||||||
|
}
|
||||||
|
fallback.WriteString(fmt.Sprintf("%s: %s", msg.Role, content))
|
||||||
|
}
|
||||||
|
return fallback.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *legacyContextManager) estimateTokens(messages []providers.Message) int {
|
||||||
|
total := 0
|
||||||
|
for _, msg := range messages {
|
||||||
|
total += estimateMessageTokens(msg)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
89
pkg/agent/context_manager.go
Normal file
89
pkg/agent/context_manager.go
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContextManager manages conversation context via a pluggable strategy.
|
||||||
|
// Exactly ONE ContextManager is active per AgentLoop, selected by config.
|
||||||
|
// The default ("legacy") preserves current summarization behavior.
|
||||||
|
type ContextManager interface {
|
||||||
|
// Assemble builds budget-aware context from the ContextManager's own storage.
|
||||||
|
// Called before BuildMessages. Returns assembled messages ready for LLM.
|
||||||
|
Assemble(ctx context.Context, req *AssembleRequest) (*AssembleResponse, error)
|
||||||
|
|
||||||
|
// Compact compresses conversation history.
|
||||||
|
// Called after turn completes (may be async internally) and on context overflow (sync).
|
||||||
|
Compact(ctx context.Context, req *CompactRequest) error
|
||||||
|
|
||||||
|
// Ingest records a message into the ContextManager's own storage.
|
||||||
|
// Called after each message is persisted to session JSONL.
|
||||||
|
Ingest(ctx context.Context, req *IngestRequest) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssembleRequest is the input to Assemble.
|
||||||
|
type AssembleRequest struct {
|
||||||
|
SessionKey string // session identifier
|
||||||
|
Budget int // context window in tokens
|
||||||
|
MaxTokens int // max response tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssembleResponse is the output of Assemble.
|
||||||
|
type AssembleResponse struct {
|
||||||
|
History []providers.Message // assembled conversation history for BuildMessages
|
||||||
|
Summary string // conversation summary embedded into system prompt by BuildMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompactRequest is the input to Compact.
|
||||||
|
type CompactRequest struct {
|
||||||
|
SessionKey string // session identifier
|
||||||
|
Reason ContextCompressReason // proactive_budget | llm_retry | summarize
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestRequest is the input to Ingest.
|
||||||
|
type IngestRequest struct {
|
||||||
|
SessionKey string // session identifier
|
||||||
|
Message providers.Message // the message just persisted
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextManagerFactory constructs a ContextManager from config.
|
||||||
|
// al provides access to the AgentLoop's runtime resources (provider, model, workspace, etc.)
|
||||||
|
// cfg is the raw JSON configuration from config.json (may be nil).
|
||||||
|
type ContextManagerFactory func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cmRegistryMu sync.RWMutex
|
||||||
|
cmRegistry = map[string]ContextManagerFactory{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterContextManager registers a named ContextManager factory.
|
||||||
|
func RegisterContextManager(name string, factory ContextManagerFactory) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("context manager name is required")
|
||||||
|
}
|
||||||
|
if factory == nil {
|
||||||
|
return fmt.Errorf("context manager %q factory is nil", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmRegistryMu.Lock()
|
||||||
|
defer cmRegistryMu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := cmRegistry[name]; exists {
|
||||||
|
return fmt.Errorf("context manager %q is already registered", name)
|
||||||
|
}
|
||||||
|
cmRegistry[name] = factory
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupContextManager(name string) (ContextManagerFactory, bool) {
|
||||||
|
cmRegistryMu.RLock()
|
||||||
|
defer cmRegistryMu.RUnlock()
|
||||||
|
|
||||||
|
f, ok := cmRegistry[name]
|
||||||
|
return f, ok
|
||||||
|
}
|
||||||
764
pkg/agent/context_manager_test.go
Normal file
764
pkg/agent/context_manager_test.go
Normal file
|
|
@ -0,0 +1,764 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Factory registry tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestRegisterContextManager_Success(t *testing.T) {
|
||||||
|
cleanup := resetCMRegistry()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
|
||||||
|
return &noopContextManager{}, nil
|
||||||
|
}
|
||||||
|
if err := RegisterContextManager("test_cm", factory); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, ok := lookupContextManager("test_cm")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected factory to be registered")
|
||||||
|
}
|
||||||
|
if f == nil {
|
||||||
|
t.Fatal("expected non-nil factory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterContextManager_EmptyName(t *testing.T) {
|
||||||
|
cleanup := resetCMRegistry()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
err := RegisterContextManager("", func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
|
||||||
|
return &noopContextManager{}, nil
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty name")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "name is required") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterContextManager_NilFactory(t *testing.T) {
|
||||||
|
cleanup := resetCMRegistry()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
err := RegisterContextManager("nil_factory", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil factory")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "factory is nil") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterContextManager_Duplicate(t *testing.T) {
|
||||||
|
cleanup := resetCMRegistry()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
|
||||||
|
return &noopContextManager{}, nil
|
||||||
|
}
|
||||||
|
if err := RegisterContextManager("dup_cm", factory); err != nil {
|
||||||
|
t.Fatalf("first registration failed: %v", err)
|
||||||
|
}
|
||||||
|
err := RegisterContextManager("dup_cm", factory)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for duplicate registration")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "already registered") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupContextManager_Unknown(t *testing.T) {
|
||||||
|
cleanup := resetCMRegistry()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, ok := lookupContextManager("nonexistent")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected lookup to fail for unknown name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolveContextManager tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestResolveContextManager_Default(t *testing.T) {
|
||||||
|
cleanup := resetCMRegistry()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: t.TempDir(),
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
ContextManager: "", // default → legacy
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
al := newCMTestAgentLoop(cfg)
|
||||||
|
|
||||||
|
cm := al.contextManager
|
||||||
|
if cm == nil {
|
||||||
|
t.Fatal("expected non-nil context manager")
|
||||||
|
}
|
||||||
|
if _, ok := cm.(*legacyContextManager); !ok {
|
||||||
|
t.Fatalf("expected *legacyContextManager, got %T", cm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveContextManager_ExplicitLegacy(t *testing.T) {
|
||||||
|
cleanup := resetCMRegistry()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: t.TempDir(),
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
ContextManager: "legacy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
al := newCMTestAgentLoop(cfg)
|
||||||
|
|
||||||
|
if _, ok := al.contextManager.(*legacyContextManager); !ok {
|
||||||
|
t.Fatalf("expected *legacyContextManager, got %T", al.contextManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveContextManager_UnknownFallsBackToLegacy(t *testing.T) {
|
||||||
|
cleanup := resetCMRegistry()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: t.TempDir(),
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
ContextManager: "unknown_cm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
al := newCMTestAgentLoop(cfg)
|
||||||
|
|
||||||
|
if _, ok := al.contextManager.(*legacyContextManager); !ok {
|
||||||
|
t.Fatalf("expected fallback to *legacyContextManager, got %T", al.contextManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveContextManager_RegisteredFactory(t *testing.T) {
|
||||||
|
cleanup := resetCMRegistry()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
|
||||||
|
return &noopContextManager{}, nil
|
||||||
|
}
|
||||||
|
if err := RegisterContextManager("custom_cm", factory); err != nil {
|
||||||
|
t.Fatalf("register failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: t.TempDir(),
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
ContextManager: "custom_cm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
al := newCMTestAgentLoop(cfg)
|
||||||
|
|
||||||
|
if _, ok := al.contextManager.(*noopContextManager); !ok {
|
||||||
|
t.Fatalf("expected *noopContextManager, got %T", al.contextManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveContextManager_FactoryError(t *testing.T) {
|
||||||
|
cleanup := resetCMRegistry()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
|
||||||
|
return nil, os.ErrPermission
|
||||||
|
}
|
||||||
|
if err := RegisterContextManager("broken_cm", factory); err != nil {
|
||||||
|
t.Fatalf("register failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: t.TempDir(),
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
ContextManager: "broken_cm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
al := newCMTestAgentLoop(cfg)
|
||||||
|
|
||||||
|
// Should fall back to legacy when factory returns error
|
||||||
|
if _, ok := al.contextManager.(*legacyContextManager); !ok {
|
||||||
|
t.Fatalf("expected fallback to *legacyContextManager on factory error, got %T", al.contextManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Legacy Assemble tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLegacyAssemble_Passthrough(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
al := newCMTestAgentLoop(cfg)
|
||||||
|
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
t.Fatal("expected default agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
history := []providers.Message{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
{Role: "assistant", Content: "hi there"},
|
||||||
|
}
|
||||||
|
agent.Sessions.SetHistory("test-session", history)
|
||||||
|
|
||||||
|
resp, err := al.contextManager.Assemble(context.Background(), &AssembleRequest{
|
||||||
|
SessionKey: "test-session",
|
||||||
|
Budget: 8000,
|
||||||
|
MaxTokens: 4096,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.History) != len(history) {
|
||||||
|
t.Fatalf("expected %d messages, got %d", len(history), len(resp.History))
|
||||||
|
}
|
||||||
|
for i, msg := range resp.History {
|
||||||
|
if msg.Content != history[i].Content || msg.Role != history[i].Role {
|
||||||
|
t.Fatalf("message %d mismatch: want %+v, got %+v", i, history[i], msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyAssemble_EmptyHistory(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
al := newCMTestAgentLoop(cfg)
|
||||||
|
|
||||||
|
resp, err := al.contextManager.Assemble(context.Background(), &AssembleRequest{
|
||||||
|
SessionKey: "test-session",
|
||||||
|
Budget: 8000,
|
||||||
|
MaxTokens: 4096,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.History) != 0 {
|
||||||
|
t.Fatalf("expected empty messages, got %d", len(resp.History))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Legacy Compact overflow tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLegacyCompact_Overflow(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
al := newCMTestAgentLoop(cfg)
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
if defaultAgent == nil {
|
||||||
|
t.Fatal("expected default agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
history := []providers.Message{
|
||||||
|
{Role: "user", Content: "msg 1"},
|
||||||
|
{Role: "assistant", Content: "resp 1"},
|
||||||
|
{Role: "user", Content: "msg 2"},
|
||||||
|
{Role: "assistant", Content: "resp 2"},
|
||||||
|
{Role: "user", Content: "msg 3"},
|
||||||
|
}
|
||||||
|
defaultAgent.Sessions.SetHistory("session-overflow", history)
|
||||||
|
|
||||||
|
sub := al.SubscribeEvents(16)
|
||||||
|
defer al.UnsubscribeEvents(sub.ID)
|
||||||
|
|
||||||
|
err := al.contextManager.Compact(context.Background(), &CompactRequest{
|
||||||
|
SessionKey: "session-overflow",
|
||||||
|
Reason: ContextCompressReasonRetry,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// After overflow compression, history should be shorter
|
||||||
|
newHistory := defaultAgent.Sessions.GetHistory("session-overflow")
|
||||||
|
if len(newHistory) >= len(history) {
|
||||||
|
t.Fatalf("expected compressed history, got %d messages (was %d)", len(newHistory), len(history))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary should contain compression note
|
||||||
|
summary := defaultAgent.Sessions.GetSummary("session-overflow")
|
||||||
|
if !strings.Contains(summary, "Emergency compression") {
|
||||||
|
t.Fatalf("expected compression note in summary, got %q", summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event should carry the proactive reason
|
||||||
|
events := collectEventStream(sub.C)
|
||||||
|
compressEvt, ok := findEvent(events, EventKindContextCompress)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected context compress event")
|
||||||
|
}
|
||||||
|
payload, ok := compressEvt.Payload.(ContextCompressPayload)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected ContextCompressPayload, got %T", compressEvt.Payload)
|
||||||
|
}
|
||||||
|
if payload.Reason != ContextCompressReasonRetry {
|
||||||
|
t.Fatalf("expected retry reason, got %q", payload.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyCompact_Overflow_ProactiveReason(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
al := newCMTestAgentLoop(cfg)
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
if defaultAgent == nil {
|
||||||
|
t.Fatal("expected default agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
history := []providers.Message{
|
||||||
|
{Role: "user", Content: "msg 1"},
|
||||||
|
{Role: "assistant", Content: "resp 1"},
|
||||||
|
{Role: "user", Content: "msg 2"},
|
||||||
|
{Role: "assistant", Content: "resp 2"},
|
||||||
|
{Role: "user", Content: "msg 3"},
|
||||||
|
}
|
||||||
|
defaultAgent.Sessions.SetHistory("session-proactive", history)
|
||||||
|
|
||||||
|
sub := al.SubscribeEvents(16)
|
||||||
|
defer al.UnsubscribeEvents(sub.ID)
|
||||||
|
|
||||||
|
err := al.contextManager.Compact(context.Background(), &CompactRequest{
|
||||||
|
SessionKey: "session-proactive",
|
||||||
|
Reason: ContextCompressReasonProactive,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := collectEventStream(sub.C)
|
||||||
|
compressEvt, ok := findEvent(events, EventKindContextCompress)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected context compress event")
|
||||||
|
}
|
||||||
|
payload, ok := compressEvt.Payload.(ContextCompressPayload)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected ContextCompressPayload, got %T", compressEvt.Payload)
|
||||||
|
}
|
||||||
|
if payload.Reason != ContextCompressReasonProactive {
|
||||||
|
t.Fatalf("expected proactive reason, got %q", payload.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyCompact_Overflow_TooShortToCompress(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
al := newCMTestAgentLoop(cfg)
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
if defaultAgent == nil {
|
||||||
|
t.Fatal("expected default agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
history := []providers.Message{
|
||||||
|
{Role: "user", Content: "only one"},
|
||||||
|
}
|
||||||
|
defaultAgent.Sessions.SetHistory("session-tiny", history)
|
||||||
|
|
||||||
|
err := al.contextManager.Compact(context.Background(), &CompactRequest{
|
||||||
|
SessionKey: "session-tiny",
|
||||||
|
Reason: ContextCompressReasonRetry,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// History should be unchanged (too short to compress)
|
||||||
|
newHistory := defaultAgent.Sessions.GetHistory("session-tiny")
|
||||||
|
if len(newHistory) != len(history) {
|
||||||
|
t.Fatalf("expected history unchanged, got %d messages (was %d)", len(newHistory), len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Legacy Compact post-turn tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLegacyCompact_PostTurn_BelowThreshold(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
al := newCMTestAgentLoop(cfg)
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
if defaultAgent == nil {
|
||||||
|
t.Fatal("expected default agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small history, below summarization thresholds
|
||||||
|
history := []providers.Message{
|
||||||
|
{Role: "user", Content: "hi"},
|
||||||
|
{Role: "assistant", Content: "hello"},
|
||||||
|
}
|
||||||
|
defaultAgent.Sessions.SetHistory("session-small", history)
|
||||||
|
|
||||||
|
err := al.contextManager.Compact(context.Background(), &CompactRequest{
|
||||||
|
SessionKey: "session-small",
|
||||||
|
Reason: ContextCompressReasonSummarize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// History should remain unchanged
|
||||||
|
newHistory := defaultAgent.Sessions.GetHistory("session-small")
|
||||||
|
if len(newHistory) != len(history) {
|
||||||
|
t.Fatalf("expected unchanged history, got %d messages (was %d)", len(newHistory), len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyCompact_PostTurn_ExceedsMessageThreshold(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: t.TempDir(),
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
ContextWindow: 8000,
|
||||||
|
SummarizeMessageThreshold: 2,
|
||||||
|
SummarizeTokenPercent: 75,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "summary"})
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
if defaultAgent == nil {
|
||||||
|
t.Fatal("expected default agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6 messages > threshold of 2
|
||||||
|
history := []providers.Message{
|
||||||
|
{Role: "user", Content: "q1"},
|
||||||
|
{Role: "assistant", Content: "a1"},
|
||||||
|
{Role: "user", Content: "q2"},
|
||||||
|
{Role: "assistant", Content: "a2"},
|
||||||
|
{Role: "user", Content: "q3"},
|
||||||
|
{Role: "assistant", Content: "a3"},
|
||||||
|
}
|
||||||
|
defaultAgent.Sessions.SetHistory("session-threshold", history)
|
||||||
|
|
||||||
|
err := al.contextManager.Compact(context.Background(), &CompactRequest{
|
||||||
|
SessionKey: "session-threshold",
|
||||||
|
Reason: ContextCompressReasonSummarize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for async summarization to complete via event
|
||||||
|
sub := al.SubscribeEvents(16)
|
||||||
|
defer al.UnsubscribeEvents(sub.ID)
|
||||||
|
|
||||||
|
waitForEvent(t, sub.C, 5*time.Second, func(evt Event) bool {
|
||||||
|
return evt.Kind == EventKindSessionSummarize
|
||||||
|
})
|
||||||
|
|
||||||
|
newHistory := defaultAgent.Sessions.GetHistory("session-threshold")
|
||||||
|
if len(newHistory) >= len(history) {
|
||||||
|
t.Fatalf("expected summarization to reduce history from %d messages, got %d", len(history), len(newHistory))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Legacy Ingest tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLegacyIngest_NoOp(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
al := newCMTestAgentLoop(cfg)
|
||||||
|
|
||||||
|
err := al.contextManager.Ingest(context.Background(), &IngestRequest{
|
||||||
|
SessionKey: "session-ingest",
|
||||||
|
Message: providers.Message{Role: "user", Content: "test"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock ContextManager — verifies dispatch through AgentLoop
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestAgentLoop_UsesCustomContextManager(t *testing.T) {
|
||||||
|
cleanup := resetCMRegistry()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock := &trackingContextManager{}
|
||||||
|
factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
|
||||||
|
return mock, nil
|
||||||
|
}
|
||||||
|
if err := RegisterContextManager("tracking_cm", factory); err != nil {
|
||||||
|
t.Fatalf("register failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: t.TempDir(),
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
ContextManager: "tracking_cm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
al := newCMTestAgentLoop(cfg)
|
||||||
|
|
||||||
|
// Verify the mock was installed
|
||||||
|
if al.contextManager != mock {
|
||||||
|
t.Fatalf("expected mock context manager, got %T", al.contextManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct method calls
|
||||||
|
_, err := mock.Assemble(context.Background(), &AssembleRequest{
|
||||||
|
SessionKey: "s1",
|
||||||
|
Budget: 8000,
|
||||||
|
MaxTokens: 4096,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Assemble error: %v", err)
|
||||||
|
}
|
||||||
|
if mock.assembleCalls.Load() != 1 {
|
||||||
|
t.Fatalf("expected 1 assemble call, got %d", mock.assembleCalls.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mock.Compact(context.Background(), &CompactRequest{
|
||||||
|
SessionKey: "s1",
|
||||||
|
Reason: ContextCompressReasonRetry,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Compact error: %v", err)
|
||||||
|
}
|
||||||
|
if mock.compactCalls.Load() != 1 {
|
||||||
|
t.Fatalf("expected 1 compact call, got %d", mock.compactCalls.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mock.Ingest(context.Background(), &IngestRequest{
|
||||||
|
SessionKey: "s1",
|
||||||
|
Message: providers.Message{Role: "user", Content: "test"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ingest error: %v", err)
|
||||||
|
}
|
||||||
|
if mock.ingestCalls.Load() != 1 {
|
||||||
|
t.Fatalf("expected 1 ingest call, got %d", mock.ingestCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestCalledDuringTurn(t *testing.T) {
|
||||||
|
cleanup := resetCMRegistry()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock := &trackingContextManager{}
|
||||||
|
factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
|
||||||
|
return mock, nil
|
||||||
|
}
|
||||||
|
if err := RegisterContextManager("ingest_track_cm", factory); err != nil {
|
||||||
|
t.Fatalf("register failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: t.TempDir(),
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
ContextManager: "ingest_track_cm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "done"})
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
if defaultAgent == nil {
|
||||||
|
t.Fatal("expected default agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a turn — ingestMessage is called for user message and final assistant message
|
||||||
|
_, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{
|
||||||
|
SessionKey: "session-ingest-turn",
|
||||||
|
Channel: "cli",
|
||||||
|
ChatID: "direct",
|
||||||
|
UserMessage: "test ingest",
|
||||||
|
DefaultResponse: defaultResponse,
|
||||||
|
EnableSummary: false,
|
||||||
|
SendResponse: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runAgentLoop failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have at least 2 ingest calls: user message + final assistant message
|
||||||
|
if mock.ingestCalls.Load() < 2 {
|
||||||
|
t.Fatalf("expected >= 2 ingest calls during turn, got %d", mock.ingestCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// forceCompression edge cases (via legacy Compact)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLegacyCompact_Overflow_SingleTurnKeepsLastUserMessage(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
al := newCMTestAgentLoop(cfg)
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
if defaultAgent == nil {
|
||||||
|
t.Fatal("expected default agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// History with only 2 messages — forceCompression should still handle it
|
||||||
|
history := []providers.Message{
|
||||||
|
{Role: "user", Content: "first question"},
|
||||||
|
{Role: "assistant", Content: "first answer"},
|
||||||
|
}
|
||||||
|
defaultAgent.Sessions.SetHistory("session-2msg", history)
|
||||||
|
|
||||||
|
err := al.contextManager.Compact(context.Background(), &CompactRequest{
|
||||||
|
SessionKey: "session-2msg",
|
||||||
|
Reason: ContextCompressReasonRetry,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newHistory := defaultAgent.Sessions.GetHistory("session-2msg")
|
||||||
|
// With 2 messages, forceCompression returns false (len <= 2), so no compression
|
||||||
|
if len(newHistory) != len(history) {
|
||||||
|
t.Fatalf("expected no compression for 2-message history, got %d", len(newHistory))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// noopContextManager is a minimal ContextManager that does nothing.
|
||||||
|
type noopContextManager struct{}
|
||||||
|
|
||||||
|
func (m *noopContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) {
|
||||||
|
return &AssembleResponse{}, nil
|
||||||
|
}
|
||||||
|
func (m *noopContextManager) Compact(_ context.Context, _ *CompactRequest) error { return nil }
|
||||||
|
func (m *noopContextManager) Ingest(_ context.Context, _ *IngestRequest) error { return nil }
|
||||||
|
|
||||||
|
// trackingContextManager tracks call counts for each method.
|
||||||
|
type trackingContextManager struct {
|
||||||
|
assembleCalls atomic.Int64
|
||||||
|
compactCalls atomic.Int64
|
||||||
|
ingestCalls atomic.Int64
|
||||||
|
mu sync.Mutex
|
||||||
|
lastAssemble *AssembleRequest
|
||||||
|
lastCompact *CompactRequest
|
||||||
|
lastIngest *IngestRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *trackingContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) {
|
||||||
|
m.assembleCalls.Add(1)
|
||||||
|
m.mu.Lock()
|
||||||
|
m.lastAssemble = req
|
||||||
|
m.mu.Unlock()
|
||||||
|
return &AssembleResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *trackingContextManager) Compact(_ context.Context, req *CompactRequest) error {
|
||||||
|
m.compactCalls.Add(1)
|
||||||
|
m.mu.Lock()
|
||||||
|
m.lastCompact = req
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *trackingContextManager) Ingest(_ context.Context, req *IngestRequest) error {
|
||||||
|
m.ingestCalls.Add(1)
|
||||||
|
m.mu.Lock()
|
||||||
|
m.lastIngest = req
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetCMRegistry clears the global factory registry and returns a cleanup
|
||||||
|
// function that restores the original state after the test.
|
||||||
|
func resetCMRegistry() func() {
|
||||||
|
cmRegistryMu.Lock()
|
||||||
|
original := make(map[string]ContextManagerFactory, len(cmRegistry))
|
||||||
|
for k, v := range cmRegistry {
|
||||||
|
original[k] = v
|
||||||
|
}
|
||||||
|
cmRegistry = make(map[string]ContextManagerFactory)
|
||||||
|
cmRegistryMu.Unlock()
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
cmRegistryMu.Lock()
|
||||||
|
cmRegistry = original
|
||||||
|
cmRegistryMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConfig(t *testing.T) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
return &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: t.TempDir(),
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCMTestAgentLoop(cfg *config.Config) *AgentLoop {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
return NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "test"})
|
||||||
|
}
|
||||||
|
|
@ -472,8 +472,9 @@ func TestAgentLoop_EmitsSessionSummarizeEvent(t *testing.T) {
|
||||||
sub := al.SubscribeEvents(16)
|
sub := al.SubscribeEvents(16)
|
||||||
defer al.UnsubscribeEvents(sub.ID)
|
defer al.UnsubscribeEvents(sub.ID)
|
||||||
|
|
||||||
turnScope := al.newTurnEventScope(defaultAgent.ID, "session-1")
|
// Use legacyContextManager's summarizeSession via contextManager interface
|
||||||
al.summarizeSession(defaultAgent, "session-1", turnScope)
|
lcm := &legacyContextManager{al: al}
|
||||||
|
lcm.summarizeSession(defaultAgent, "session-1")
|
||||||
|
|
||||||
events := collectEventStream(sub.C)
|
events := collectEventStream(sub.C)
|
||||||
summaryEvt, ok := findEvent(events, EventKindSessionSummarize)
|
summaryEvt, ok := findEvent(events, EventKindSessionSummarize)
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,8 @@ const (
|
||||||
ContextCompressReasonProactive ContextCompressReason = "proactive_budget"
|
ContextCompressReasonProactive ContextCompressReason = "proactive_budget"
|
||||||
// ContextCompressReasonRetry indicates compression during context-error retry handling.
|
// ContextCompressReasonRetry indicates compression during context-error retry handling.
|
||||||
ContextCompressReasonRetry ContextCompressReason = "llm_retry"
|
ContextCompressReasonRetry ContextCompressReason = "llm_retry"
|
||||||
|
// ContextCompressReasonSummarize indicates post-turn async summarization.
|
||||||
|
ContextCompressReasonSummarize ContextCompressReason = "summarize"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContextCompressPayload describes a forced history compression.
|
// ContextCompressPayload describes a forced history compression.
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,12 @@ func NewAgentInstance(
|
||||||
|
|
||||||
if cfg.Tools.IsToolEnabled("read_file") {
|
if cfg.Tools.IsToolEnabled("read_file") {
|
||||||
maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
|
maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
|
||||||
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
|
switch cfg.Tools.ReadFile.EffectiveMode() {
|
||||||
|
case config.ReadFileModeLines:
|
||||||
|
toolsRegistry.Register(tools.NewReadFileLinesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
|
||||||
|
default:
|
||||||
|
toolsRegistry.Register(tools.NewReadFileBytesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if cfg.Tools.IsToolEnabled("write_file") {
|
if cfg.Tools.IsToolEnabled("write_file") {
|
||||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,58 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewAgentInstance_PreservesDistinctLimiterIdentityForSharedResolvedModel(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "glm-4.7",
|
||||||
|
ModelFallbacks: []string{"glm-4.7__key_1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []*config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "glm-4.7",
|
||||||
|
Model: "zhipu/glm-4.7",
|
||||||
|
RPM: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "glm-4.7__key_1",
|
||||||
|
Model: "zhipu/glm-4.7",
|
||||||
|
RPM: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{})
|
||||||
|
if len(agent.Candidates) != 2 {
|
||||||
|
t.Fatalf("len(Candidates) = %d, want 2", len(agent.Candidates))
|
||||||
|
}
|
||||||
|
|
||||||
|
first := agent.Candidates[0]
|
||||||
|
second := agent.Candidates[1]
|
||||||
|
if first.Provider != "zhipu" || first.Model != "glm-4.7" {
|
||||||
|
t.Fatalf("first candidate = %s/%s, want zhipu/glm-4.7", first.Provider, first.Model)
|
||||||
|
}
|
||||||
|
if second.Provider != "zhipu" || second.Model != "glm-4.7" {
|
||||||
|
t.Fatalf("second candidate = %s/%s, want zhipu/glm-4.7", second.Provider, second.Model)
|
||||||
|
}
|
||||||
|
if first.IdentityKey != "model_name:glm-4.7" {
|
||||||
|
t.Fatalf("first identity key = %q, want %q", first.IdentityKey, "model_name:glm-4.7")
|
||||||
|
}
|
||||||
|
if second.IdentityKey != "model_name:glm-4.7__key_1" {
|
||||||
|
t.Fatalf("second identity key = %q, want %q", second.IdentityKey, "model_name:glm-4.7__key_1")
|
||||||
|
}
|
||||||
|
if first.RPM != 1 {
|
||||||
|
t.Fatalf("first RPM = %d, want 1", first.RPM)
|
||||||
|
}
|
||||||
|
if second.RPM != 3 {
|
||||||
|
t.Fatalf("second RPM = %d, want 3", second.RPM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
|
func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
mediaDir := media.TempDir()
|
mediaDir := media.TempDir()
|
||||||
|
|
@ -248,6 +300,47 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewAgentInstance_ReadFileModeSelectsSchema(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: workspace,
|
||||||
|
ModelName: "test-model",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
ReadFile: config.ReadFileToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Mode: config.ReadFileModeLines,
|
||||||
|
MaxReadFileSize: 4096,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{})
|
||||||
|
readTool, ok := agent.Tools.Get("read_file")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("read_file tool not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
params := readTool.Parameters()
|
||||||
|
props, _ := params["properties"].(map[string]any)
|
||||||
|
if _, ok := props["start_line"]; !ok {
|
||||||
|
t.Fatalf("expected line-mode schema to expose start_line, got %#v", props)
|
||||||
|
}
|
||||||
|
if _, ok := props["max_lines"]; !ok {
|
||||||
|
t.Fatalf("expected line-mode schema to expose max_lines, got %#v", props)
|
||||||
|
}
|
||||||
|
if _, ok := props["offset"]; ok {
|
||||||
|
t.Fatalf("did not expect line-mode schema to expose offset, got %#v", props)
|
||||||
|
}
|
||||||
|
if _, ok := props["length"]; ok {
|
||||||
|
t.Fatalf("did not expect line-mode schema to expose length, got %#v", props)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) {
|
func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -47,11 +48,11 @@ type AgentLoop struct {
|
||||||
|
|
||||||
// Runtime state
|
// Runtime state
|
||||||
running atomic.Bool
|
running atomic.Bool
|
||||||
summarizing sync.Map
|
contextManager ContextManager
|
||||||
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
|
||||||
|
|
@ -75,6 +76,8 @@ type processOptions struct {
|
||||||
SessionKey string // Session identifier for history/context
|
SessionKey string // Session identifier for history/context
|
||||||
Channel string // Target channel for tool execution
|
Channel string // Target channel for tool execution
|
||||||
ChatID string // Target chat ID for tool execution
|
ChatID string // Target chat ID for tool execution
|
||||||
|
MessageID string // Current inbound platform message ID
|
||||||
|
ReplyToMessageID string // Current inbound reply target message ID
|
||||||
SenderID string // Current sender ID for dynamic context
|
SenderID string // Current sender ID for dynamic context
|
||||||
SenderDisplayName string // Current sender display name for dynamic context
|
SenderDisplayName string // Current sender display name for dynamic context
|
||||||
UserMessage string // User message content (may include prefix)
|
UserMessage string // User message content (may include prefix)
|
||||||
|
|
@ -104,6 +107,7 @@ const (
|
||||||
metadataKeyAccountID = "account_id"
|
metadataKeyAccountID = "account_id"
|
||||||
metadataKeyGuildID = "guild_id"
|
metadataKeyGuildID = "guild_id"
|
||||||
metadataKeyTeamID = "team_id"
|
metadataKeyTeamID = "team_id"
|
||||||
|
metadataKeyReplyToMessage = "reply_to_message_id"
|
||||||
metadataKeyParentPeerKind = "parent_peer_kind"
|
metadataKeyParentPeerKind = "parent_peer_kind"
|
||||||
metadataKeyParentPeerID = "parent_peer_id"
|
metadataKeyParentPeerID = "parent_peer_id"
|
||||||
)
|
)
|
||||||
|
|
@ -115,9 +119,18 @@ func NewAgentLoop(
|
||||||
) *AgentLoop {
|
) *AgentLoop {
|
||||||
registry := NewAgentRegistry(cfg, provider)
|
registry := NewAgentRegistry(cfg, provider)
|
||||||
|
|
||||||
// Set up shared fallback chain
|
// Set up shared fallback chain with rate limiting.
|
||||||
cooldown := providers.NewCooldownTracker()
|
cooldown := providers.NewCooldownTracker()
|
||||||
fallbackChain := providers.NewFallbackChain(cooldown)
|
rl := providers.NewRateLimiterRegistry()
|
||||||
|
// Register rate limiters for all agents' candidates so that RPM limits
|
||||||
|
// configured in ModelConfig are enforced before each LLM call.
|
||||||
|
for _, agentID := range registry.ListAgentIDs() {
|
||||||
|
if agent, ok := registry.GetAgent(agentID); ok {
|
||||||
|
rl.RegisterCandidates(agent.Candidates)
|
||||||
|
rl.RegisterCandidates(agent.LightCandidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fallbackChain := providers.NewFallbackChain(cooldown, rl)
|
||||||
|
|
||||||
// Create state manager using default agent's workspace for channel recording
|
// Create state manager using default agent's workspace for channel recording
|
||||||
defaultAgent := registry.GetDefaultAgent()
|
defaultAgent := registry.GetDefaultAgent()
|
||||||
|
|
@ -133,13 +146,13 @@ func NewAgentLoop(
|
||||||
registry: registry,
|
registry: registry,
|
||||||
state: stateManager,
|
state: stateManager,
|
||||||
eventBus: eventBus,
|
eventBus: eventBus,
|
||||||
summarizing: sync.Map{},
|
|
||||||
fallback: fallbackChain,
|
fallback: fallbackChain,
|
||||||
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
|
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
|
||||||
steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)),
|
steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)),
|
||||||
}
|
}
|
||||||
al.hooks = NewHookManager(eventBus)
|
al.hooks = NewHookManager(eventBus)
|
||||||
configureHookManagerFromConfig(al.hooks, cfg)
|
configureHookManagerFromConfig(al.hooks, cfg)
|
||||||
|
al.contextManager = al.resolveContextManager()
|
||||||
|
|
||||||
// Register shared tools to all agents (now that al is created)
|
// Register shared tools to all agents (now that al is created)
|
||||||
registerSharedTools(al, cfg, msgBus, registry, provider)
|
registerSharedTools(al, cfg, msgBus, registry, provider)
|
||||||
|
|
@ -156,6 +169,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)
|
||||||
|
|
@ -222,17 +242,37 @@ func registerSharedTools(
|
||||||
// Message tool
|
// Message tool
|
||||||
if cfg.Tools.IsToolEnabled("message") {
|
if cfg.Tools.IsToolEnabled("message") {
|
||||||
messageTool := tools.NewMessageTool()
|
messageTool := tools.NewMessageTool()
|
||||||
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
messageTool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
|
||||||
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer pubCancel()
|
defer pubCancel()
|
||||||
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
Content: content,
|
Content: content,
|
||||||
|
ReplyToMessageID: replyToMessageID,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
agent.Tools.Register(messageTool)
|
agent.Tools.Register(messageTool)
|
||||||
}
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("reaction") {
|
||||||
|
reactionTool := tools.NewReactionTool()
|
||||||
|
reactionTool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error {
|
||||||
|
if al.channelManager == nil {
|
||||||
|
return fmt.Errorf("channel manager not configured")
|
||||||
|
}
|
||||||
|
ch, ok := al.channelManager.GetChannel(channel)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("channel %s not found", channel)
|
||||||
|
}
|
||||||
|
rc, ok := ch.(channels.ReactionCapable)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("channel %s does not support reactions", channel)
|
||||||
|
}
|
||||||
|
_, err := rc.ReactToMessage(ctx, chatID, messageID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
agent.Tools.Register(reactionTool)
|
||||||
|
}
|
||||||
|
|
||||||
// Send file tool (outbound media via MediaStore — store injected later by SetMediaStore)
|
// Send file tool (outbound media via MediaStore — store injected later by SetMediaStore)
|
||||||
if cfg.Tools.IsToolEnabled("send_file") {
|
if cfg.Tools.IsToolEnabled("send_file") {
|
||||||
|
|
@ -246,6 +286,21 @@ func registerSharedTools(
|
||||||
agent.Tools.Register(sendFileTool)
|
agent.Tools.Register(sendFileTool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ttsProvider != nil {
|
||||||
|
agent.Tools.Register(tools.NewSendTTSTool(ttsProvider, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Tools.IsToolEnabled("load_image") {
|
||||||
|
loadImageTool := tools.NewLoadImageTool(
|
||||||
|
agent.Workspace,
|
||||||
|
cfg.Agents.Defaults.RestrictToWorkspace,
|
||||||
|
cfg.Agents.Defaults.GetMaxMediaSize(),
|
||||||
|
nil,
|
||||||
|
allowReadPaths,
|
||||||
|
)
|
||||||
|
agent.Tools.Register(loadImageTool)
|
||||||
|
}
|
||||||
|
|
||||||
// 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")
|
||||||
|
|
@ -288,6 +343,14 @@ func registerSharedTools(
|
||||||
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
|
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
|
||||||
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
||||||
|
|
||||||
|
// Inject a media resolver so the legacy RunToolLoop fallback path can
|
||||||
|
// resolve media:// refs in the same way the main AgentLoop does.
|
||||||
|
// This keeps subagent vision support working even when the optimized
|
||||||
|
// sub-turn spawner path is unavailable.
|
||||||
|
subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message {
|
||||||
|
return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize())
|
||||||
|
})
|
||||||
|
|
||||||
// Set the spawner that links into AgentLoop's turnState
|
// Set the spawner that links into AgentLoop's turnState
|
||||||
subagentManager.SetSpawner(func(
|
subagentManager.SetSpawner(func(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|
@ -461,7 +524,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
if target == nil {
|
if target == nil {
|
||||||
cancelDrain()
|
cancelDrain()
|
||||||
if finalResponse != "" {
|
if finalResponse != "" {
|
||||||
al.publishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, finalResponse)
|
al.PublishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, finalResponse)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -521,7 +584,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if finalResponse != "" {
|
if finalResponse != "" {
|
||||||
al.publishResponseIfNeeded(ctx, target.Channel, target.ChatID, finalResponse)
|
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, finalResponse)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
@ -603,7 +666,7 @@ func (al *AgentLoop) Stop() {
|
||||||
al.running.Store(false)
|
al.running.Store(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) publishResponseIfNeeded(ctx context.Context, channel, chatID, response string) {
|
func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string) {
|
||||||
if response == "" {
|
if response == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -937,6 +1000,7 @@ func (al *AgentLoop) ReloadProviderAndConfig(
|
||||||
go func() {
|
go func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
|
logger.RecoverPanicNoExit(r)
|
||||||
panicErr = fmt.Errorf("panic during registry creation: %v", r)
|
panicErr = fmt.Errorf("panic during registry creation: %v", r)
|
||||||
logger.ErrorCF("agent", "Panic during registry creation",
|
logger.ErrorCF("agent", "Panic during registry creation",
|
||||||
map[string]any{"panic": r})
|
map[string]any{"panic": r})
|
||||||
|
|
@ -977,8 +1041,15 @@ func (al *AgentLoop) ReloadProviderAndConfig(
|
||||||
al.cfg = cfg
|
al.cfg = cfg
|
||||||
al.registry = registry
|
al.registry = registry
|
||||||
|
|
||||||
// Also update fallback chain with new config
|
// Also update fallback chain with new config; rebuild rate limiter registry.
|
||||||
al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker())
|
newRL := providers.NewRateLimiterRegistry()
|
||||||
|
for _, agentID := range registry.ListAgentIDs() {
|
||||||
|
if agent, ok := registry.GetAgent(agentID); ok {
|
||||||
|
newRL.RegisterCandidates(agent.Candidates)
|
||||||
|
newRL.RegisterCandidates(agent.LightCandidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker(), newRL)
|
||||||
|
|
||||||
al.mu.Unlock()
|
al.mu.Unlock()
|
||||||
|
|
||||||
|
|
@ -1036,10 +1107,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1060,19 +1136,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)
|
||||||
|
|
@ -1092,15 +1172,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++ {
|
||||||
newContent += "\n[voice: " + transcriptions[idx] + "]"
|
if transcriptions[idx] != "" {
|
||||||
|
newContent += "\n[voice: " + transcriptions[idx] + "]"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
msg.Content = newContent
|
msg.Content = newContent
|
||||||
|
msg.Media = keptMedia
|
||||||
return msg, true
|
return msg, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1315,6 +1401,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
ChatID: msg.ChatID,
|
ChatID: msg.ChatID,
|
||||||
|
MessageID: msg.MessageID,
|
||||||
|
ReplyToMessageID: inboundMetadata(msg, metadataKeyReplyToMessage),
|
||||||
SenderID: msg.SenderID,
|
SenderID: msg.SenderID,
|
||||||
SenderDisplayName: msg.Sender.DisplayName,
|
SenderDisplayName: msg.Sender.DisplayName,
|
||||||
UserMessage: msg.Content,
|
UserMessage: msg.Content,
|
||||||
|
|
@ -1618,8 +1706,15 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
|
||||||
var history []providers.Message
|
var history []providers.Message
|
||||||
var summary string
|
var summary string
|
||||||
if !ts.opts.NoHistory {
|
if !ts.opts.NoHistory {
|
||||||
history = ts.agent.Sessions.GetHistory(ts.sessionKey)
|
// ContextManager assembles budget-aware history and summary.
|
||||||
summary = ts.agent.Sessions.GetSummary(ts.sessionKey)
|
if resp, err := al.contextManager.Assemble(turnCtx, &AssembleRequest{
|
||||||
|
SessionKey: ts.sessionKey,
|
||||||
|
Budget: ts.agent.ContextWindow,
|
||||||
|
MaxTokens: ts.agent.MaxTokens,
|
||||||
|
}); err == nil && resp != nil {
|
||||||
|
history = resp.History
|
||||||
|
summary = resp.Summary
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ts.captureRestorePoint(history, summary)
|
ts.captureRestorePoint(history, summary)
|
||||||
|
|
||||||
|
|
@ -1644,22 +1739,27 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
|
||||||
if isOverContextBudget(ts.agent.ContextWindow, messages, toolDefs, ts.agent.MaxTokens) {
|
if isOverContextBudget(ts.agent.ContextWindow, messages, toolDefs, ts.agent.MaxTokens) {
|
||||||
logger.WarnCF("agent", "Proactive compression: context budget exceeded before LLM call",
|
logger.WarnCF("agent", "Proactive compression: context budget exceeded before LLM call",
|
||||||
map[string]any{"session_key": ts.sessionKey})
|
map[string]any{"session_key": ts.sessionKey})
|
||||||
if compression, ok := al.forceCompression(ts.agent, ts.sessionKey); ok {
|
if err := al.contextManager.Compact(turnCtx, &CompactRequest{
|
||||||
al.emitEvent(
|
SessionKey: ts.sessionKey,
|
||||||
EventKindContextCompress,
|
Reason: ContextCompressReasonProactive,
|
||||||
ts.eventMeta("runTurn", "turn.context.compress"),
|
}); err != nil {
|
||||||
ContextCompressPayload{
|
logger.WarnCF("agent", "Proactive compact failed", map[string]any{
|
||||||
Reason: ContextCompressReasonProactive,
|
"session_key": ts.sessionKey,
|
||||||
DroppedMessages: compression.DroppedMessages,
|
"error": err.Error(),
|
||||||
RemainingMessages: compression.RemainingMessages,
|
})
|
||||||
},
|
}
|
||||||
)
|
ts.refreshRestorePointFromSession(ts.agent)
|
||||||
ts.refreshRestorePointFromSession(ts.agent)
|
// Re-assemble from CM after compact.
|
||||||
|
if resp, err := al.contextManager.Assemble(turnCtx, &AssembleRequest{
|
||||||
|
SessionKey: ts.sessionKey,
|
||||||
|
Budget: ts.agent.ContextWindow,
|
||||||
|
MaxTokens: ts.agent.MaxTokens,
|
||||||
|
}); err == nil && resp != nil {
|
||||||
|
history = resp.History
|
||||||
|
summary = resp.Summary
|
||||||
}
|
}
|
||||||
newHistory := ts.agent.Sessions.GetHistory(ts.sessionKey)
|
|
||||||
newSummary := ts.agent.Sessions.GetSummary(ts.sessionKey)
|
|
||||||
messages = ts.agent.ContextBuilder.BuildMessages(
|
messages = ts.agent.ContextBuilder.BuildMessages(
|
||||||
newHistory, newSummary, ts.userMessage,
|
history, summary, ts.userMessage,
|
||||||
ts.media, ts.channel, ts.chatID,
|
ts.media, ts.channel, ts.chatID,
|
||||||
ts.opts.SenderID, ts.opts.SenderDisplayName,
|
ts.opts.SenderID, ts.opts.SenderDisplayName,
|
||||||
activeSkillNames(ts.agent, ts.opts)...,
|
activeSkillNames(ts.agent, ts.opts)...,
|
||||||
|
|
@ -1681,6 +1781,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
|
||||||
ts.agent.Sessions.AddMessage(ts.sessionKey, rootMsg.Role, rootMsg.Content)
|
ts.agent.Sessions.AddMessage(ts.sessionKey, rootMsg.Role, rootMsg.Content)
|
||||||
}
|
}
|
||||||
ts.recordPersistedMessage(rootMsg)
|
ts.recordPersistedMessage(rootMsg)
|
||||||
|
ts.ingestMessage(turnCtx, al, rootMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
activeCandidates, activeModel, usedLight := al.selectCandidates(ts.agent, ts.userMessage, messages)
|
activeCandidates, activeModel, usedLight := al.selectCandidates(ts.agent, ts.userMessage, messages)
|
||||||
|
|
@ -1809,6 +1910,14 @@ turnLoop:
|
||||||
providerToolDefs = filtered
|
providerToolDefs = filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve media:// refs produced by tool results (e.g. load_image).
|
||||||
|
// Skipped on iteration 1 because inbound user media is already resolved
|
||||||
|
// before entering the loop; only subsequent iterations can contain new
|
||||||
|
// tool-generated media refs that need base64 encoding.
|
||||||
|
if iteration > 1 {
|
||||||
|
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
||||||
|
}
|
||||||
|
|
||||||
callMessages := messages
|
callMessages := messages
|
||||||
if gracefulTerminal {
|
if gracefulTerminal {
|
||||||
callMessages = append(append([]providers.Message(nil), messages...), ts.interruptHintMessage())
|
callMessages = append(append([]providers.Message(nil), messages...), ts.interruptHintMessage())
|
||||||
|
|
@ -2016,23 +2125,27 @@ turnLoop:
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if compression, ok := al.forceCompression(ts.agent, ts.sessionKey); ok {
|
if compactErr := al.contextManager.Compact(turnCtx, &CompactRequest{
|
||||||
al.emitEvent(
|
SessionKey: ts.sessionKey,
|
||||||
EventKindContextCompress,
|
Reason: ContextCompressReasonRetry,
|
||||||
ts.eventMeta("runTurn", "turn.context.compress"),
|
}); compactErr != nil {
|
||||||
ContextCompressPayload{
|
logger.WarnCF("agent", "Context overflow compact failed", map[string]any{
|
||||||
Reason: ContextCompressReasonRetry,
|
"session_key": ts.sessionKey,
|
||||||
DroppedMessages: compression.DroppedMessages,
|
"error": compactErr.Error(),
|
||||||
RemainingMessages: compression.RemainingMessages,
|
})
|
||||||
},
|
}
|
||||||
)
|
ts.refreshRestorePointFromSession(ts.agent)
|
||||||
ts.refreshRestorePointFromSession(ts.agent)
|
// Re-assemble from CM after compact.
|
||||||
|
if asmResp, asmErr := al.contextManager.Assemble(turnCtx, &AssembleRequest{
|
||||||
|
SessionKey: ts.sessionKey,
|
||||||
|
Budget: ts.agent.ContextWindow,
|
||||||
|
MaxTokens: ts.agent.MaxTokens,
|
||||||
|
}); asmErr == nil && asmResp != nil {
|
||||||
|
history = asmResp.History
|
||||||
|
summary = asmResp.Summary
|
||||||
}
|
}
|
||||||
|
|
||||||
newHistory := ts.agent.Sessions.GetHistory(ts.sessionKey)
|
|
||||||
newSummary := ts.agent.Sessions.GetSummary(ts.sessionKey)
|
|
||||||
messages = ts.agent.ContextBuilder.BuildMessages(
|
messages = ts.agent.ContextBuilder.BuildMessages(
|
||||||
newHistory, newSummary, "",
|
history, summary, "",
|
||||||
nil, ts.channel, ts.chatID, ts.opts.SenderID, ts.opts.SenderDisplayName,
|
nil, ts.channel, ts.chatID, ts.opts.SenderID, ts.opts.SenderDisplayName,
|
||||||
activeSkillNames(ts.agent, ts.opts)...,
|
activeSkillNames(ts.agent, ts.opts)...,
|
||||||
)
|
)
|
||||||
|
|
@ -2205,6 +2318,7 @@ turnLoop:
|
||||||
if !ts.opts.NoHistory {
|
if !ts.opts.NoHistory {
|
||||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, assistantMsg)
|
ts.agent.Sessions.AddFullMessage(ts.sessionKey, assistantMsg)
|
||||||
ts.recordPersistedMessage(assistantMsg)
|
ts.recordPersistedMessage(assistantMsg)
|
||||||
|
ts.ingestMessage(turnCtx, al, assistantMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
ts.setPhase(TurnPhaseTools)
|
ts.setPhase(TurnPhaseTools)
|
||||||
|
|
@ -2384,8 +2498,15 @@ turnLoop:
|
||||||
}
|
}
|
||||||
|
|
||||||
toolStart := time.Now()
|
toolStart := time.Now()
|
||||||
toolResult := ts.agent.Tools.ExecuteWithContext(
|
execCtx := tools.WithToolInboundContext(
|
||||||
turnCtx,
|
turnCtx,
|
||||||
|
ts.channel,
|
||||||
|
ts.chatID,
|
||||||
|
ts.opts.MessageID,
|
||||||
|
ts.opts.ReplyToMessageID,
|
||||||
|
)
|
||||||
|
toolResult := ts.agent.Tools.ExecuteWithContext(
|
||||||
|
execCtx,
|
||||||
toolName,
|
toolName,
|
||||||
toolArgs,
|
toolArgs,
|
||||||
ts.channel,
|
ts.channel,
|
||||||
|
|
@ -2432,6 +2553,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 {
|
||||||
|
|
@ -2470,6 +2613,13 @@ turnLoop:
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(toolResult.Media) > 0 && !toolResult.ResponseHandled {
|
if len(toolResult.Media) > 0 && !toolResult.ResponseHandled {
|
||||||
|
// For tools like load_image that produce media refs without sending them
|
||||||
|
// to the user channel (ResponseHandled == false), both Media and ArtifactTags
|
||||||
|
// coexist on the result:
|
||||||
|
// - Media: carries media:// refs that resolveMediaRefs will base64-encode
|
||||||
|
// into image_url parts in the next LLM iteration (enabling vision).
|
||||||
|
// - ArtifactTags: exposes the local file path as a structured [file:…] tag
|
||||||
|
// in the tool result text, so the LLM knows an artifact was produced.
|
||||||
toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media)
|
toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2477,19 +2627,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
|
||||||
|
|
@ -2502,6 +2639,9 @@ turnLoop:
|
||||||
Content: contentForLLM,
|
Content: contentForLLM,
|
||||||
ToolCallID: toolCallID,
|
ToolCallID: toolCallID,
|
||||||
}
|
}
|
||||||
|
if len(toolResult.Media) > 0 && !toolResult.ResponseHandled {
|
||||||
|
toolResultMsg.Media = append(toolResultMsg.Media, toolResult.Media...)
|
||||||
|
}
|
||||||
al.emitEvent(
|
al.emitEvent(
|
||||||
EventKindToolExecEnd,
|
EventKindToolExecEnd,
|
||||||
ts.eventMeta("runTurn", "turn.tool.end"),
|
ts.eventMeta("runTurn", "turn.tool.end"),
|
||||||
|
|
@ -2518,6 +2658,7 @@ turnLoop:
|
||||||
if !ts.opts.NoHistory {
|
if !ts.opts.NoHistory {
|
||||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg)
|
ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg)
|
||||||
ts.recordPersistedMessage(toolResultMsg)
|
ts.recordPersistedMessage(toolResultMsg)
|
||||||
|
ts.ingestMessage(turnCtx, al, toolResultMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
|
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
|
||||||
|
|
@ -2617,6 +2758,7 @@ turnLoop:
|
||||||
if !ts.opts.NoHistory {
|
if !ts.opts.NoHistory {
|
||||||
ts.agent.Sessions.AddMessage(ts.sessionKey, summaryMsg.Role, summaryMsg.Content)
|
ts.agent.Sessions.AddMessage(ts.sessionKey, summaryMsg.Role, summaryMsg.Content)
|
||||||
ts.recordPersistedMessage(summaryMsg)
|
ts.recordPersistedMessage(summaryMsg)
|
||||||
|
ts.ingestMessage(turnCtx, al, summaryMsg)
|
||||||
if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil {
|
if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil {
|
||||||
turnStatus = TurnEndStatusError
|
turnStatus = TurnEndStatusError
|
||||||
al.emitEvent(
|
al.emitEvent(
|
||||||
|
|
@ -2631,7 +2773,7 @@ turnLoop:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ts.opts.EnableSummary {
|
if ts.opts.EnableSummary {
|
||||||
al.maybeSummarize(ts.agent, ts.sessionKey, ts.scope)
|
al.contextManager.Compact(turnCtx, &CompactRequest{SessionKey: ts.sessionKey, Reason: ContextCompressReasonSummarize})
|
||||||
}
|
}
|
||||||
|
|
||||||
ts.setPhase(TurnPhaseCompleted)
|
ts.setPhase(TurnPhaseCompleted)
|
||||||
|
|
@ -2686,6 +2828,7 @@ turnLoop:
|
||||||
finalMsg := providers.Message{Role: "assistant", Content: finalContent}
|
finalMsg := providers.Message{Role: "assistant", Content: finalContent}
|
||||||
ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content)
|
ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content)
|
||||||
ts.recordPersistedMessage(finalMsg)
|
ts.recordPersistedMessage(finalMsg)
|
||||||
|
ts.ingestMessage(turnCtx, al, finalMsg)
|
||||||
if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil {
|
if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil {
|
||||||
turnStatus = TurnEndStatusError
|
turnStatus = TurnEndStatusError
|
||||||
al.emitEvent(
|
al.emitEvent(
|
||||||
|
|
@ -2701,7 +2844,13 @@ turnLoop:
|
||||||
}
|
}
|
||||||
|
|
||||||
if ts.opts.EnableSummary {
|
if ts.opts.EnableSummary {
|
||||||
al.maybeSummarize(ts.agent, ts.sessionKey, ts.scope)
|
al.contextManager.Compact(
|
||||||
|
turnCtx,
|
||||||
|
&CompactRequest{
|
||||||
|
SessionKey: ts.sessionKey,
|
||||||
|
Reason: ContextCompressReasonSummarize,
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ts.setPhase(TurnPhaseCompleted)
|
ts.setPhase(TurnPhaseCompleted)
|
||||||
|
|
@ -2780,103 +2929,28 @@ func (al *AgentLoop) selectCandidates(
|
||||||
return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true
|
return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
// resolveContextManager selects the ContextManager implementation based on config.
|
||||||
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey string, turnScope turnEventScope) {
|
func (al *AgentLoop) resolveContextManager() ContextManager {
|
||||||
newHistory := agent.Sessions.GetHistory(sessionKey)
|
name := al.cfg.Agents.Defaults.ContextManager
|
||||||
tokenEstimate := al.estimateTokens(newHistory)
|
if name == "" || name == "legacy" {
|
||||||
threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100
|
return &legacyContextManager{al: al}
|
||||||
|
|
||||||
if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold {
|
|
||||||
summarizeKey := agent.ID + ":" + sessionKey
|
|
||||||
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
|
|
||||||
go func() {
|
|
||||||
defer al.summarizing.Delete(summarizeKey)
|
|
||||||
logger.Debug("Memory threshold reached. Optimizing conversation history...")
|
|
||||||
al.summarizeSession(agent, sessionKey, turnScope)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
factory, ok := lookupContextManager(name)
|
||||||
|
if !ok {
|
||||||
type compressionResult struct {
|
logger.WarnCF("agent", "Unknown context manager, falling back to legacy", map[string]any{
|
||||||
DroppedMessages int
|
"name": name,
|
||||||
RemainingMessages int
|
})
|
||||||
}
|
return &legacyContextManager{al: al}
|
||||||
|
|
||||||
// forceCompression aggressively reduces context when the limit is hit.
|
|
||||||
// It drops the oldest ~50% of Turns (a Turn is a complete user→LLM→response
|
|
||||||
// cycle, as defined in #1316), so tool-call sequences are never split.
|
|
||||||
//
|
|
||||||
// If the history is a single Turn with no safe split point, the function
|
|
||||||
// falls back to keeping only the most recent user message. This breaks
|
|
||||||
// Turn atomicity as a last resort to avoid a context-exceeded loop.
|
|
||||||
//
|
|
||||||
// Session history contains only user/assistant/tool messages — the system
|
|
||||||
// prompt is built dynamically by BuildMessages and is NOT stored here.
|
|
||||||
// The compression note is recorded in the session summary so that
|
|
||||||
// BuildMessages can include it in the next system prompt.
|
|
||||||
func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) (compressionResult, bool) {
|
|
||||||
history := agent.Sessions.GetHistory(sessionKey)
|
|
||||||
if len(history) <= 2 {
|
|
||||||
return compressionResult{}, false
|
|
||||||
}
|
}
|
||||||
|
cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al)
|
||||||
// Split at a Turn boundary so no tool-call sequence is torn apart.
|
if err != nil {
|
||||||
// parseTurnBoundaries gives us the start of each Turn; we drop the
|
logger.WarnCF("agent", "Failed to create context manager, falling back to legacy", map[string]any{
|
||||||
// oldest half of Turns and keep the most recent ones.
|
"name": name,
|
||||||
turns := parseTurnBoundaries(history)
|
"error": err.Error(),
|
||||||
var mid int
|
})
|
||||||
if len(turns) >= 2 {
|
return &legacyContextManager{al: al}
|
||||||
mid = turns[len(turns)/2]
|
|
||||||
} else {
|
|
||||||
// Fewer than 2 Turns — fall back to message-level midpoint
|
|
||||||
// aligned to the nearest Turn boundary.
|
|
||||||
mid = findSafeBoundary(history, len(history)/2)
|
|
||||||
}
|
}
|
||||||
var keptHistory []providers.Message
|
return cm
|
||||||
if mid <= 0 {
|
|
||||||
// No safe Turn boundary — the entire history is a single Turn
|
|
||||||
// (e.g. one user message followed by a massive tool response).
|
|
||||||
// Keeping everything would leave the agent stuck in a context-
|
|
||||||
// exceeded loop, so fall back to keeping only the most recent
|
|
||||||
// user message. This breaks Turn atomicity as a last resort.
|
|
||||||
for i := len(history) - 1; i >= 0; i-- {
|
|
||||||
if history[i].Role == "user" {
|
|
||||||
keptHistory = []providers.Message{history[i]}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
keptHistory = history[mid:]
|
|
||||||
}
|
|
||||||
|
|
||||||
droppedCount := len(history) - len(keptHistory)
|
|
||||||
|
|
||||||
// Record compression in the session summary so BuildMessages includes it
|
|
||||||
// in the system prompt. We do not modify history messages themselves.
|
|
||||||
existingSummary := agent.Sessions.GetSummary(sessionKey)
|
|
||||||
compressionNote := fmt.Sprintf(
|
|
||||||
"[Emergency compression dropped %d oldest messages due to context limit]",
|
|
||||||
droppedCount,
|
|
||||||
)
|
|
||||||
if existingSummary != "" {
|
|
||||||
compressionNote = existingSummary + "\n\n" + compressionNote
|
|
||||||
}
|
|
||||||
agent.Sessions.SetSummary(sessionKey, compressionNote)
|
|
||||||
|
|
||||||
agent.Sessions.SetHistory(sessionKey, keptHistory)
|
|
||||||
agent.Sessions.Save(sessionKey)
|
|
||||||
|
|
||||||
logger.WarnCF("agent", "Forced compression executed", map[string]any{
|
|
||||||
"session_key": sessionKey,
|
|
||||||
"dropped_msgs": droppedCount,
|
|
||||||
"new_count": len(keptHistory),
|
|
||||||
})
|
|
||||||
|
|
||||||
return compressionResult{
|
|
||||||
DroppedMessages: droppedCount,
|
|
||||||
RemainingMessages: len(keptHistory),
|
|
||||||
}, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStartupInfo returns information about loaded tools and skills for logging.
|
// GetStartupInfo returns information about loaded tools and skills for logging.
|
||||||
|
|
@ -2968,247 +3042,13 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// summarizeSession summarizes the conversation history for a session.
|
// summarizeSession summarizes the conversation history for a session.
|
||||||
func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string, turnScope turnEventScope) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
history := agent.Sessions.GetHistory(sessionKey)
|
|
||||||
summary := agent.Sessions.GetSummary(sessionKey)
|
|
||||||
|
|
||||||
// Keep the most recent Turns for continuity, aligned to a Turn boundary
|
|
||||||
// so that no tool-call sequence is split.
|
|
||||||
if len(history) <= 4 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
safeCut := findSafeBoundary(history, len(history)-4)
|
|
||||||
if safeCut <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
keepCount := len(history) - safeCut
|
|
||||||
toSummarize := history[:safeCut]
|
|
||||||
|
|
||||||
// Oversized Message Guard
|
|
||||||
maxMessageTokens := agent.ContextWindow / 2
|
|
||||||
validMessages := make([]providers.Message, 0)
|
|
||||||
omitted := false
|
|
||||||
|
|
||||||
for _, m := range toSummarize {
|
|
||||||
if m.Role != "user" && m.Role != "assistant" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
msgTokens := len(m.Content) / 2
|
|
||||||
if msgTokens > maxMessageTokens {
|
|
||||||
omitted = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
validMessages = append(validMessages, m)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(validMessages) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
maxSummarizationMessages = 10
|
|
||||||
llmMaxRetries = 3
|
|
||||||
llmTemperature = 0.3
|
|
||||||
fallbackMaxContentLength = 200
|
|
||||||
)
|
|
||||||
|
|
||||||
// Multi-Part Summarization
|
|
||||||
var finalSummary string
|
|
||||||
if len(validMessages) > maxSummarizationMessages {
|
|
||||||
mid := len(validMessages) / 2
|
|
||||||
|
|
||||||
mid = al.findNearestUserMessage(validMessages, mid)
|
|
||||||
|
|
||||||
part1 := validMessages[:mid]
|
|
||||||
part2 := validMessages[mid:]
|
|
||||||
|
|
||||||
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
|
|
||||||
s2, _ := al.summarizeBatch(ctx, agent, part2, "")
|
|
||||||
|
|
||||||
mergePrompt := fmt.Sprintf(
|
|
||||||
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
|
|
||||||
s1,
|
|
||||||
s2,
|
|
||||||
)
|
|
||||||
|
|
||||||
resp, err := al.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries)
|
|
||||||
if err == nil && resp.Content != "" {
|
|
||||||
finalSummary = resp.Content
|
|
||||||
} else {
|
|
||||||
finalSummary = s1 + " " + s2
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if omitted && finalSummary != "" {
|
|
||||||
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]"
|
|
||||||
}
|
|
||||||
|
|
||||||
if finalSummary != "" {
|
|
||||||
agent.Sessions.SetSummary(sessionKey, finalSummary)
|
|
||||||
agent.Sessions.TruncateHistory(sessionKey, keepCount)
|
|
||||||
agent.Sessions.Save(sessionKey)
|
|
||||||
al.emitEvent(
|
|
||||||
EventKindSessionSummarize,
|
|
||||||
turnScope.meta(0, "summarizeSession", "turn.session.summarize"),
|
|
||||||
SessionSummarizePayload{
|
|
||||||
SummarizedMessages: len(validMessages),
|
|
||||||
KeptMessages: keepCount,
|
|
||||||
SummaryLen: len(finalSummary),
|
|
||||||
OmittedOversized: omitted,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// findNearestUserMessage finds the nearest user message to the given index.
|
// findNearestUserMessage finds the nearest user message to the given index.
|
||||||
// It searches backward first, then forward if no user message is found.
|
// It searches backward first, then forward if no user message is found.
|
||||||
func (al *AgentLoop) findNearestUserMessage(messages []providers.Message, mid int) int {
|
|
||||||
originalMid := mid
|
|
||||||
|
|
||||||
for mid > 0 && messages[mid].Role != "user" {
|
|
||||||
mid--
|
|
||||||
}
|
|
||||||
|
|
||||||
if messages[mid].Role == "user" {
|
|
||||||
return mid
|
|
||||||
}
|
|
||||||
|
|
||||||
mid = originalMid
|
|
||||||
for mid < len(messages) && messages[mid].Role != "user" {
|
|
||||||
mid++
|
|
||||||
}
|
|
||||||
|
|
||||||
if mid < len(messages) {
|
|
||||||
return mid
|
|
||||||
}
|
|
||||||
|
|
||||||
return originalMid
|
|
||||||
}
|
|
||||||
|
|
||||||
// retryLLMCall calls the LLM with retry logic.
|
// retryLLMCall calls the LLM with retry logic.
|
||||||
func (al *AgentLoop) retryLLMCall(
|
|
||||||
ctx context.Context,
|
|
||||||
agent *AgentInstance,
|
|
||||||
prompt string,
|
|
||||||
maxRetries int,
|
|
||||||
) (*providers.LLMResponse, error) {
|
|
||||||
const (
|
|
||||||
llmTemperature = 0.3
|
|
||||||
)
|
|
||||||
|
|
||||||
var resp *providers.LLMResponse
|
|
||||||
var err error
|
|
||||||
|
|
||||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
|
||||||
al.activeRequests.Add(1)
|
|
||||||
resp, err = func() (*providers.LLMResponse, error) {
|
|
||||||
defer al.activeRequests.Done()
|
|
||||||
return agent.Provider.Chat(
|
|
||||||
ctx,
|
|
||||||
[]providers.Message{{Role: "user", Content: prompt}},
|
|
||||||
nil,
|
|
||||||
agent.Model,
|
|
||||||
map[string]any{
|
|
||||||
"max_tokens": agent.MaxTokens,
|
|
||||||
"temperature": llmTemperature,
|
|
||||||
"prompt_cache_key": agent.ID,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err == nil && resp != nil && resp.Content != "" {
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
if attempt < maxRetries-1 {
|
|
||||||
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// summarizeBatch summarizes a batch of messages.
|
// summarizeBatch summarizes a batch of messages.
|
||||||
func (al *AgentLoop) summarizeBatch(
|
|
||||||
ctx context.Context,
|
|
||||||
agent *AgentInstance,
|
|
||||||
batch []providers.Message,
|
|
||||||
existingSummary string,
|
|
||||||
) (string, error) {
|
|
||||||
const (
|
|
||||||
llmMaxRetries = 3
|
|
||||||
llmTemperature = 0.3
|
|
||||||
fallbackMinContentLength = 200
|
|
||||||
fallbackMaxContentPercent = 10
|
|
||||||
)
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(
|
|
||||||
"Provide a concise summary of this conversation segment, preserving core context and key points.\n",
|
|
||||||
)
|
|
||||||
if existingSummary != "" {
|
|
||||||
sb.WriteString("Existing context: ")
|
|
||||||
sb.WriteString(existingSummary)
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
sb.WriteString("\nCONVERSATION:\n")
|
|
||||||
for _, m := range batch {
|
|
||||||
fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content)
|
|
||||||
}
|
|
||||||
prompt := sb.String()
|
|
||||||
|
|
||||||
response, err := al.retryLLMCall(ctx, agent, prompt, llmMaxRetries)
|
|
||||||
if err == nil && response.Content != "" {
|
|
||||||
return strings.TrimSpace(response.Content), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var fallback strings.Builder
|
|
||||||
fallback.WriteString("Conversation summary: ")
|
|
||||||
for i, m := range batch {
|
|
||||||
if i > 0 {
|
|
||||||
fallback.WriteString(" | ")
|
|
||||||
}
|
|
||||||
content := strings.TrimSpace(m.Content)
|
|
||||||
runes := []rune(content)
|
|
||||||
if len(runes) == 0 {
|
|
||||||
fallback.WriteString(fmt.Sprintf("%s: ", m.Role))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
keepLength := len(runes) * fallbackMaxContentPercent / 100
|
|
||||||
if keepLength < fallbackMinContentLength {
|
|
||||||
keepLength = fallbackMinContentLength
|
|
||||||
}
|
|
||||||
|
|
||||||
if keepLength > len(runes) {
|
|
||||||
keepLength = len(runes)
|
|
||||||
}
|
|
||||||
|
|
||||||
content = string(runes[:keepLength])
|
|
||||||
if keepLength < len(runes) {
|
|
||||||
content += "..."
|
|
||||||
}
|
|
||||||
fallback.WriteString(fmt.Sprintf("%s: %s", m.Role, content))
|
|
||||||
}
|
|
||||||
return fallback.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// estimateTokens estimates the number of tokens in a message list.
|
// estimateTokens estimates the number of tokens in a message list.
|
||||||
// Counts Content, ToolCalls arguments, and ToolCallID metadata so that
|
// Counts Content, ToolCalls arguments, and ToolCallID metadata so that
|
||||||
// tool-heavy conversations are not systematically undercounted.
|
// tool-heavy conversations are not systematically undercounted.
|
||||||
func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
|
|
||||||
total := 0
|
|
||||||
for _, m := range messages {
|
|
||||||
total += estimateMessageTokens(m)
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
func (al *AgentLoop) handleCommand(
|
func (al *AgentLoop) handleCommand(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
msg bus.InboundMessage,
|
msg bus.InboundMessage,
|
||||||
|
|
@ -3405,7 +3245,7 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
|
||||||
return "", fmt.Errorf("failed to initialize model %q: %w", value, err)
|
return "", fmt.Errorf("failed to initialize model %q: %w", value, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, modelCfg.Model, agent.Fallbacks)
|
nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, value, agent.Fallbacks)
|
||||||
if len(nextCandidates) == 0 {
|
if len(nextCandidates) == 0 {
|
||||||
return "", fmt.Errorf("model %q did not resolve to any provider candidates", value)
|
return "", fmt.Errorf("model %q did not resolve to any provider candidates", value)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,23 +25,25 @@ import (
|
||||||
|
|
||||||
type fakeChannel struct{ id string }
|
type fakeChannel struct{ id string }
|
||||||
|
|
||||||
func (f *fakeChannel) Name() string { return "fake" }
|
func (f *fakeChannel) Name() string { return "fake" }
|
||||||
func (f *fakeChannel) Start(ctx context.Context) error { return nil }
|
func (f *fakeChannel) Start(ctx context.Context) error { return nil }
|
||||||
func (f *fakeChannel) Stop(ctx context.Context) error { return nil }
|
func (f *fakeChannel) Stop(ctx context.Context) error { return nil }
|
||||||
func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil }
|
func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
||||||
func (f *fakeChannel) IsRunning() bool { return true }
|
return nil, nil
|
||||||
func (f *fakeChannel) IsAllowed(string) bool { return true }
|
}
|
||||||
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
|
func (f *fakeChannel) IsRunning() bool { return true }
|
||||||
func (f *fakeChannel) ReasoningChannelID() string { return f.id }
|
func (f *fakeChannel) IsAllowed(string) bool { return true }
|
||||||
|
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
|
||||||
|
func (f *fakeChannel) ReasoningChannelID() string { return f.id }
|
||||||
|
|
||||||
type fakeMediaChannel struct {
|
type fakeMediaChannel struct {
|
||||||
fakeChannel
|
fakeChannel
|
||||||
sentMedia []bus.OutboundMediaMessage
|
sentMedia []bus.OutboundMediaMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
func (f *fakeMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
|
||||||
f.sentMedia = append(f.sentMedia, msg)
|
f.sentMedia = append(f.sentMedia, msg)
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStartedTestChannelManager(
|
func newStartedTestChannelManager(
|
||||||
|
|
@ -531,6 +533,20 @@ func TestToolContext_Updates(t *testing.T) {
|
||||||
if got := tools.ToolChannel(context.Background()); got != "" {
|
if got := tools.ToolChannel(context.Background()); got != "" {
|
||||||
t.Errorf("expected empty channel from bare context, got %q", got)
|
t.Errorf("expected empty channel from bare context, got %q", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inboundCtx := tools.WithToolInboundContext(
|
||||||
|
context.Background(),
|
||||||
|
"telegram",
|
||||||
|
"chat-42",
|
||||||
|
"msg-123",
|
||||||
|
"msg-100",
|
||||||
|
)
|
||||||
|
if got := tools.ToolMessageID(inboundCtx); got != "msg-123" {
|
||||||
|
t.Errorf("expected messageID 'msg-123', got %q", got)
|
||||||
|
}
|
||||||
|
if got := tools.ToolReplyToMessageID(inboundCtx); got != "msg-100" {
|
||||||
|
t.Errorf("expected replyToMessageID 'msg-100', got %q", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved
|
// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved
|
||||||
|
|
|
||||||
|
|
@ -8,44 +8,102 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func buildModelListResolver(cfg *config.Config) func(raw string) (string, bool) {
|
func ensureProtocolModel(model string) string {
|
||||||
ensureProtocol := func(model string) string {
|
model = strings.TrimSpace(model)
|
||||||
model = strings.TrimSpace(model)
|
if model == "" {
|
||||||
if model == "" {
|
return ""
|
||||||
return ""
|
}
|
||||||
}
|
if strings.Contains(model, "/") {
|
||||||
if strings.Contains(model, "/") {
|
return model
|
||||||
return model
|
}
|
||||||
}
|
return "openai/" + model
|
||||||
return "openai/" + model
|
}
|
||||||
|
|
||||||
|
func modelConfigIdentityKey(mc *config.ModelConfig) string {
|
||||||
|
if mc == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if name := strings.TrimSpace(mc.ModelName); name != "" {
|
||||||
|
return "model_name:" + name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateFromModelConfig(
|
||||||
|
defaultProvider string,
|
||||||
|
mc *config.ModelConfig,
|
||||||
|
) (providers.FallbackCandidate, bool) {
|
||||||
|
if mc == nil {
|
||||||
|
return providers.FallbackCandidate{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(raw string) (string, bool) {
|
ref := providers.ParseModelRef(ensureProtocolModel(mc.Model), defaultProvider)
|
||||||
raw = strings.TrimSpace(raw)
|
if ref == nil {
|
||||||
if raw == "" || cfg == nil {
|
return providers.FallbackCandidate{}, false
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
|
|
||||||
return ensureProtocol(mc.Model), true
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range cfg.ModelList {
|
|
||||||
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
|
|
||||||
if fullModel == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if fullModel == raw {
|
|
||||||
return ensureProtocol(fullModel), true
|
|
||||||
}
|
|
||||||
_, modelID := providers.ExtractProtocol(fullModel)
|
|
||||||
if modelID == raw {
|
|
||||||
return ensureProtocol(fullModel), true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return providers.FallbackCandidate{
|
||||||
|
Provider: ref.Provider,
|
||||||
|
Model: ref.Model,
|
||||||
|
RPM: mc.RPM,
|
||||||
|
IdentityKey: modelConfigIdentityKey(mc),
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" || cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
|
||||||
|
return mc
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
mc := cfg.ModelList[i]
|
||||||
|
if mc == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fullModel := strings.TrimSpace(mc.Model)
|
||||||
|
if fullModel == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fullModel == raw {
|
||||||
|
return mc
|
||||||
|
}
|
||||||
|
_, modelID := providers.ExtractProtocol(fullModel)
|
||||||
|
if modelID == raw {
|
||||||
|
return mc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveModelCandidate(
|
||||||
|
cfg *config.Config,
|
||||||
|
defaultProvider string,
|
||||||
|
raw string,
|
||||||
|
) (providers.FallbackCandidate, bool) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return providers.FallbackCandidate{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if mc := lookupModelConfigByRef(cfg, raw); mc != nil {
|
||||||
|
return candidateFromModelConfig(defaultProvider, mc)
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := providers.ParseModelRef(raw, defaultProvider)
|
||||||
|
if ref == nil {
|
||||||
|
return providers.FallbackCandidate{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return providers.FallbackCandidate{
|
||||||
|
Provider: ref.Provider,
|
||||||
|
Model: ref.Model,
|
||||||
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveModelCandidates(
|
func resolveModelCandidates(
|
||||||
|
|
@ -54,14 +112,29 @@ func resolveModelCandidates(
|
||||||
primary string,
|
primary string,
|
||||||
fallbacks []string,
|
fallbacks []string,
|
||||||
) []providers.FallbackCandidate {
|
) []providers.FallbackCandidate {
|
||||||
return providers.ResolveCandidatesWithLookup(
|
seen := make(map[string]bool)
|
||||||
providers.ModelConfig{
|
candidates := make([]providers.FallbackCandidate, 0, 1+len(fallbacks))
|
||||||
Primary: primary,
|
|
||||||
Fallbacks: fallbacks,
|
addCandidate := func(raw string) {
|
||||||
},
|
candidate, ok := resolveModelCandidate(cfg, defaultProvider, raw)
|
||||||
defaultProvider,
|
if !ok {
|
||||||
buildModelListResolver(cfg),
|
return
|
||||||
)
|
}
|
||||||
|
|
||||||
|
key := candidate.StableKey()
|
||||||
|
if seen[key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
addCandidate(primary)
|
||||||
|
for _, fallback := range fallbacks {
|
||||||
|
addCandidate(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvedCandidateModel(candidates []providers.FallbackCandidate, fallback string) string {
|
func resolvedCandidateModel(candidates []providers.FallbackCandidate, fallback string) string {
|
||||||
|
|
|
||||||
|
|
@ -427,6 +427,7 @@ func spawnSubTurn(
|
||||||
// 7. Defer cleanup: deliver result (for async), emit End event, and recover from panics
|
// 7. Defer cleanup: deliver result (for async), emit End event, and recover from panics
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
|
logger.RecoverPanicNoExit(r)
|
||||||
err = fmt.Errorf("subturn panicked: %v", r)
|
err = fmt.Errorf("subturn panicked: %v", r)
|
||||||
result = nil
|
result = nil
|
||||||
logger.ErrorCF("subturn", "SubTurn panicked", map[string]any{
|
logger.ErrorCF("subturn", "SubTurn panicked", map[string]any{
|
||||||
|
|
@ -510,6 +511,7 @@ func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, re
|
||||||
// We use defer/recover to catch any unlikely channel panics if it were ever closed.
|
// We use defer/recover to catch any unlikely channel panics if it were ever closed.
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
|
logger.RecoverPanicNoExit(r)
|
||||||
logger.WarnCF("subturn", "recovered panic sending to pendingResults", map[string]any{
|
logger.WarnCF("subturn", "recovered panic sending to pendingResults", map[string]any{
|
||||||
"parent_id": parentTS.turnID,
|
"parent_id": parentTS.turnID,
|
||||||
"child_id": childID,
|
"child_id": childID,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/session"
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
|
@ -338,6 +339,23 @@ func (ts *turnState) refreshRestorePointFromSession(agent *AgentInstance) {
|
||||||
ts.captureRestorePoint(history, summary)
|
ts.captureRestorePoint(history, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ingestMessage calls the ContextManager's Ingest method for a persisted message.
|
||||||
|
// Errors are logged but never block the turn.
|
||||||
|
func (ts *turnState) ingestMessage(ctx context.Context, al *AgentLoop, msg providers.Message) {
|
||||||
|
if al.contextManager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := al.contextManager.Ingest(ctx, &IngestRequest{
|
||||||
|
SessionKey: ts.sessionKey,
|
||||||
|
Message: msg,
|
||||||
|
}); err != nil {
|
||||||
|
logger.WarnCF("agent", "Context manager ingest failed", map[string]any{
|
||||||
|
"session_key": ts.sessionKey,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (ts *turnState) restoreSession(agent *AgentInstance) error {
|
func (ts *turnState) restoreSession(agent *AgentInstance) error {
|
||||||
ts.mu.RLock()
|
ts.mu.RLock()
|
||||||
history := append([]providers.Message(nil), ts.restorePointHistory...)
|
history := append([]providers.Message(nil), ts.restorePointHistory...)
|
||||||
|
|
|
||||||
166
pkg/audio/asr/README.md
Normal file
166
pkg/audio/asr/README.md
Normal 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
166
pkg/audio/asr/README_zh.md
Normal 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 其他模型配置方式保持一致。
|
||||||
|
|
||||||
|
## 推荐配置方式
|
||||||
|
|
||||||
|
### 方案 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"
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 你可以不写 `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`。
|
||||||
|
|
||||||
|
### 方案 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"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案 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"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 其他支持 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
252
pkg/audio/asr/agent.go
Normal 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
196
pkg/audio/asr/agent_test.go
Normal 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
131
pkg/audio/asr/asr.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
@ -149,10 +193,10 @@ func TestDetectTranscriber(t *testing.T) {
|
||||||
name: "voice model name takes priority over elevenlabs",
|
name: "voice model name takes priority over elevenlabs",
|
||||||
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",
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package voice
|
package asr
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package voice
|
package asr
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
@ -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")
|
||||||
245
pkg/audio/asr/whisper_transcriber.go
Normal file
245
pkg/audio/asr/whisper_transcriber.go
Normal 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"
|
||||||
|
}
|
||||||
102
pkg/audio/asr/whisper_transcriber_test.go
Normal file
102
pkg/audio/asr/whisper_transcriber_test.go
Normal 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
57
pkg/audio/ogg.go
Normal 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
146
pkg/audio/ogg_test.go
Normal 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
96
pkg/audio/sentence.go
Normal 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
|
||||||
|
}
|
||||||
69
pkg/audio/sentence_test.go
Normal file
69
pkg/audio/sentence_test.go
Normal 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
137
pkg/audio/tts/README.md
Normal 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
137
pkg/audio/tts/README_zh.md
Normal 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。
|
||||||
|
|
||||||
|
这是当前推荐且受支持的配置方式。
|
||||||
|
|
||||||
|
## 推荐配置方式
|
||||||
|
|
||||||
|
### 方案 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"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案 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"
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你使用自定义的 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 Key,PicoClaw 就会使用这个模型条目的配置创建一个 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 兼容 provider,PicoClaw 会尽量保留你提供的基础路径,只确保它最终以 `/audio/speech` 结尾。
|
||||||
|
- 如果没有设置 `api_base`,并且模型前缀是已知 provider,PicoClaw 会自动使用该 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
162
pkg/audio/tts/mimo_tts.go
Normal 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
126
pkg/audio/tts/openai_tts.go
Normal 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
151
pkg/audio/tts/tts.go
Normal 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
247
pkg/audio/tts/tts_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue