Merge branch 'main' of https://github.com/linhaolin1/picoclaw into feature/channel-vk
This commit is contained in:
commit
6cf8e90971
93 changed files with 6556 additions and 1693 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
|
||||||
17
Makefile
17
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,7 +130,7 @@ 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)
|
||||||
@$(MAKE) -C web build \
|
@GOARCH=${ARCH} $(MAKE) -C web build \
|
||||||
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)" \
|
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)" \
|
||||||
WEB_GO='$(WEB_GO)' \
|
WEB_GO='$(WEB_GO)' \
|
||||||
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
|
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
|
||||||
|
|
@ -324,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
|
||||||
|
|
|
||||||
27
README.fr.md
27
README.fr.md
|
|
@ -306,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 :
|
||||||
|
|
@ -323,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**
|
|
||||||
|
|
||||||
Téléchargez l'APK depuis [picoclaw.io](https://picoclaw.io/download/) et installez-le directement. Pas besoin de Termux !
|
|
||||||
|
|
||||||
<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**
|
||||||
|
|
|
||||||
27
README.id.md
27
README.id.md
|
|
@ -303,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:
|
||||||
|
|
@ -320,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**
|
|
||||||
|
|
||||||
Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux!
|
|
||||||
|
|
||||||
<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**
|
||||||
|
|
|
||||||
27
README.it.md
27
README.it.md
|
|
@ -303,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:
|
||||||
|
|
@ -320,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: Installazione APK**
|
|
||||||
|
|
||||||
Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux!
|
|
||||||
|
|
||||||
<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**
|
||||||
|
|
|
||||||
27
README.ja.md
27
README.ja.md
|
|
@ -303,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. 以下のコマンドを実行:
|
||||||
|
|
@ -320,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 インストール**
|
|
||||||
|
|
||||||
[picoclaw.io](https://picoclaw.io/download/) から APK をダウンロードして直接インストール。Termux 不要!
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Terminal Launcher(リソース制約環境向け)</b></summary>
|
|
||||||
|
|
||||||
`picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。
|
`picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。
|
||||||
|
|
||||||
**1. 初期化**
|
**1. 初期化**
|
||||||
|
|
|
||||||
27
README.md
27
README.md
|
|
@ -303,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:
|
||||||
|
|
@ -320,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**
|
|
||||||
|
|
||||||
Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required!
|
|
||||||
|
|
||||||
<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**
|
||||||
|
|
|
||||||
27
README.my.md
27
README.my.md
|
|
@ -300,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:
|
||||||
|
|
@ -317,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: Pasang APK**
|
|
||||||
|
|
||||||
Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan!
|
|
||||||
|
|
||||||
<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**
|
||||||
|
|
|
||||||
|
|
@ -303,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:
|
||||||
|
|
@ -320,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**
|
|
||||||
|
|
||||||
Baixe o APK de [picoclaw.io](https://picoclaw.io/download/) e instale diretamente. Sem necessidade de Termux!
|
|
||||||
|
|
||||||
<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**
|
||||||
|
|
|
||||||
27
README.vi.md
27
README.vi.md
|
|
@ -303,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:
|
||||||
|
|
@ -320,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**
|
|
||||||
|
|
||||||
Tải APK từ [picoclaw.io](https://picoclaw.io/download/) và cài đặt trực tiếp. Không cần Termux!
|
|
||||||
|
|
||||||
<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**
|
||||||
|
|
|
||||||
27
README.zh.md
27
README.zh.md
|
|
@ -303,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. 执行以下命令:
|
||||||
|
|
@ -320,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 安装**
|
|
||||||
|
|
||||||
从 [picoclaw.io](https://picoclaw.io/download/) 下载 APK 并直接安装,无需 Termux!
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Terminal Launcher(适用于资源受限环境)</b></summary>
|
|
||||||
|
|
||||||
对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。
|
对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。
|
||||||
|
|
||||||
**1. 初始化**
|
**1. 初始化**
|
||||||
|
|
|
||||||
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 |
|
|
@ -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,11 @@
|
||||||
"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_name": "lmstudio-local",
|
||||||
"model": "lmstudio/openai/gpt-oss-20b"
|
"model": "lmstudio/openai/gpt-oss-20b"
|
||||||
|
|
@ -416,7 +421,8 @@
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"read_file": {
|
"read_file": {
|
||||||
"enabled": true
|
"enabled": true,
|
||||||
|
"mode": "bytes"
|
||||||
},
|
},
|
||||||
"send_tts": {
|
"send_tts": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
|
|
|
||||||
|
|
@ -301,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 |
|
||||||
|
|
|
||||||
|
|
@ -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**
|
||||||
|
|
@ -190,6 +208,7 @@ Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne p
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
```
|
|
||||||
|
|
@ -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**
|
||||||
|
|
@ -201,6 +219,7 @@ Anthropic API への直接アクセスや、Anthropic のネイティブメッ
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
||||||
|
|
@ -106,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.
|
||||||
|
|
@ -247,6 +267,7 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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**
|
||||||
|
|
@ -190,6 +208,7 @@ Para acesso direto à API Anthropic ou endpoints personalizados que suportam ape
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
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` |
|
||||||
|
|
@ -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**
|
||||||
|
|
@ -190,6 +208,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
||||||
|
|
@ -102,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。
|
||||||
|
|
@ -232,6 +252,7 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -24,6 +24,7 @@ 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
|
||||||
|
|
@ -49,6 +50,7 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
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.8 // 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
|
||||||
|
|
|
||||||
11
go.sum
11
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=
|
||||||
|
|
@ -186,6 +188,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=
|
||||||
|
|
@ -314,7 +318,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-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=
|
||||||
|
|
@ -335,6 +342,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=
|
||||||
|
|
@ -357,11 +365,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=
|
||||||
|
|
@ -375,6 +385,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=
|
||||||
|
|
|
||||||
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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ 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
|
||||||
|
|
@ -119,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()
|
||||||
|
|
@ -137,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)
|
||||||
|
|
@ -281,6 +290,17 @@ func registerSharedTools(
|
||||||
agent.Tools.Register(tools.NewSendTTSTool(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")
|
||||||
|
|
@ -323,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,
|
||||||
|
|
@ -972,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})
|
||||||
|
|
@ -1012,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()
|
||||||
|
|
||||||
|
|
@ -1670,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)
|
||||||
|
|
||||||
|
|
@ -1696,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,
|
|
||||||
ts.eventMeta("runTurn", "turn.context.compress"),
|
|
||||||
ContextCompressPayload{
|
|
||||||
Reason: ContextCompressReasonProactive,
|
Reason: ContextCompressReasonProactive,
|
||||||
DroppedMessages: compression.DroppedMessages,
|
}); err != nil {
|
||||||
RemainingMessages: compression.RemainingMessages,
|
logger.WarnCF("agent", "Proactive compact failed", map[string]any{
|
||||||
},
|
"session_key": ts.sessionKey,
|
||||||
)
|
"error": err.Error(),
|
||||||
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)...,
|
||||||
|
|
@ -1733,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)
|
||||||
|
|
@ -1861,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())
|
||||||
|
|
@ -2068,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,
|
|
||||||
ts.eventMeta("runTurn", "turn.context.compress"),
|
|
||||||
ContextCompressPayload{
|
|
||||||
Reason: ContextCompressReasonRetry,
|
Reason: ContextCompressReasonRetry,
|
||||||
DroppedMessages: compression.DroppedMessages,
|
}); compactErr != nil {
|
||||||
RemainingMessages: compression.RemainingMessages,
|
logger.WarnCF("agent", "Context overflow compact failed", map[string]any{
|
||||||
},
|
"session_key": ts.sessionKey,
|
||||||
)
|
"error": compactErr.Error(),
|
||||||
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)...,
|
||||||
)
|
)
|
||||||
|
|
@ -2257,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)
|
||||||
|
|
@ -2551,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2570,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"),
|
||||||
|
|
@ -2586,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 {
|
||||||
|
|
@ -2685,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(
|
||||||
|
|
@ -2699,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)
|
||||||
|
|
@ -2754,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(
|
||||||
|
|
@ -2769,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)
|
||||||
|
|
@ -2848,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 {
|
||||||
|
logger.WarnCF("agent", "Unknown context manager, falling back to legacy", map[string]any{
|
||||||
type compressionResult struct {
|
"name": name,
|
||||||
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.
|
|
||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split at a Turn boundary so no tool-call sequence is torn apart.
|
|
||||||
// parseTurnBoundaries gives us the start of each Turn; we drop the
|
|
||||||
// oldest half of Turns and keep the most recent ones.
|
|
||||||
turns := parseTurnBoundaries(history)
|
|
||||||
var mid int
|
|
||||||
if len(turns) >= 2 {
|
|
||||||
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
|
|
||||||
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 &legacyContextManager{al: al}
|
||||||
return compressionResult{
|
}
|
||||||
DroppedMessages: droppedCount,
|
cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al)
|
||||||
RemainingMessages: len(keptHistory),
|
if err != nil {
|
||||||
}, true
|
logger.WarnCF("agent", "Failed to create context manager, falling back to legacy", map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return &legacyContextManager{al: al}
|
||||||
|
}
|
||||||
|
return cm
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStartupInfo returns information about loaded tools and skills for logging.
|
// GetStartupInfo returns information about loaded tools and skills for logging.
|
||||||
|
|
@ -3036,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,
|
||||||
|
|
@ -3473,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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,7 @@ 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 ""
|
||||||
|
|
@ -20,32 +19,91 @@ func buildModelListResolver(cfg *config.Config) func(raw string) (string, bool)
|
||||||
return "openai/" + model
|
return "openai/" + model
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(raw string) (string, bool) {
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := providers.ParseModelRef(ensureProtocolModel(mc.Model), defaultProvider)
|
||||||
|
if ref == nil {
|
||||||
|
return providers.FallbackCandidate{}, 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)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" || cfg == nil {
|
if raw == "" || cfg == nil {
|
||||||
return "", false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
|
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
|
||||||
return ensureProtocol(mc.Model), true
|
return mc
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range cfg.ModelList {
|
for i := range cfg.ModelList {
|
||||||
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
|
mc := cfg.ModelList[i]
|
||||||
|
if mc == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fullModel := strings.TrimSpace(mc.Model)
|
||||||
if fullModel == "" {
|
if fullModel == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if fullModel == raw {
|
if fullModel == raw {
|
||||||
return ensureProtocol(fullModel), true
|
return mc
|
||||||
}
|
}
|
||||||
_, modelID := providers.ExtractProtocol(fullModel)
|
_, modelID := providers.ExtractProtocol(fullModel)
|
||||||
if modelID == raw {
|
if modelID == raw {
|
||||||
return ensureProtocol(fullModel), true
|
return mc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", false
|
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...)
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,7 @@ func streamOggOpusToDiscord(ctx context.Context, vc *discordgo.VoiceConnection,
|
||||||
defer func() {
|
defer func() {
|
||||||
if rec := recover(); rec != nil {
|
if rec := recover(); rec != nil {
|
||||||
retErr = fmt.Errorf("voice connection closed during playback")
|
retErr = fmt.Errorf("voice connection closed during playback")
|
||||||
|
logger.RecoverPanicNoExit(rec)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -517,6 +518,8 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
||||||
|
|
||||||
dispatchCtx, cancel := context.WithCancel(ctx)
|
dispatchCtx, cancel := context.WithCancel(ctx)
|
||||||
m.dispatchTask = &asyncTask{cancel: cancel}
|
m.dispatchTask = &asyncTask{cancel: cancel}
|
||||||
|
failedStarts := make([]error, 0, len(m.channels))
|
||||||
|
failedNames := make([]string, 0, len(m.channels))
|
||||||
|
|
||||||
for name, channel := range m.channels {
|
for name, channel := range m.channels {
|
||||||
logger.InfoCF("channels", "Starting channel", map[string]any{
|
logger.InfoCF("channels", "Starting channel", map[string]any{
|
||||||
|
|
@ -527,6 +530,8 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
||||||
"channel": name,
|
"channel": name,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
failedStarts = append(failedStarts, fmt.Errorf("channel %s: %w", name, err))
|
||||||
|
failedNames = append(failedNames, name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Lazily create worker only after channel starts successfully
|
// Lazily create worker only after channel starts successfully
|
||||||
|
|
@ -536,6 +541,36 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
||||||
go m.runMediaWorker(dispatchCtx, name, w)
|
go m.runMediaWorker(dispatchCtx, name, w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(m.channels) > 0 && len(m.workers) == 0 {
|
||||||
|
if m.dispatchTask != nil {
|
||||||
|
m.dispatchTask.cancel()
|
||||||
|
m.dispatchTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(failedNames)
|
||||||
|
if len(failedStarts) == 0 {
|
||||||
|
return fmt.Errorf("failed to start any enabled channels")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.ErrorCF("channels", "All enabled channels failed to start", map[string]any{
|
||||||
|
"failed": len(failedNames),
|
||||||
|
"total": len(m.channels),
|
||||||
|
"failed_channels": failedNames,
|
||||||
|
})
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to start any enabled channels: %w", errors.Join(failedStarts...))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(failedNames) > 0 {
|
||||||
|
sort.Strings(failedNames)
|
||||||
|
logger.WarnCF("channels", "Some channels failed to start", map[string]any{
|
||||||
|
"failed": len(failedNames),
|
||||||
|
"started": len(m.workers),
|
||||||
|
"total": len(m.channels),
|
||||||
|
"failed_channels": failedNames,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Start the dispatcher that reads from the bus and routes to workers
|
// Start the dispatcher that reads from the bus and routes to workers
|
||||||
go m.dispatchOutbound(dispatchCtx)
|
go m.dispatchOutbound(dispatchCtx)
|
||||||
go m.dispatchOutboundMedia(dispatchCtx)
|
go m.dispatchOutboundMedia(dispatchCtx)
|
||||||
|
|
@ -557,7 +592,11 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoC("channels", "All channels started")
|
logger.InfoCF("channels", "Channel startup completed", map[string]any{
|
||||||
|
"started": len(m.workers),
|
||||||
|
"failed": len(failedNames),
|
||||||
|
"total": len(m.channels),
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import (
|
||||||
type mockChannel struct {
|
type mockChannel struct {
|
||||||
BaseChannel
|
BaseChannel
|
||||||
sendFn func(ctx context.Context, msg bus.OutboundMessage) error
|
sendFn func(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
|
startFn func(ctx context.Context) error
|
||||||
|
stopFn func(ctx context.Context) error
|
||||||
sentMessages []bus.OutboundMessage
|
sentMessages []bus.OutboundMessage
|
||||||
placeholdersSent int
|
placeholdersSent int
|
||||||
editedMessages int
|
editedMessages int
|
||||||
|
|
@ -33,8 +35,19 @@ func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
|
||||||
return nil, m.sendFn(ctx, msg)
|
return nil, m.sendFn(ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockChannel) Start(ctx context.Context) error { return nil }
|
func (m *mockChannel) Start(ctx context.Context) error {
|
||||||
func (m *mockChannel) Stop(ctx context.Context) error { return nil }
|
if m.startFn != nil {
|
||||||
|
return m.startFn(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockChannel) Stop(ctx context.Context) error {
|
||||||
|
if m.stopFn != nil {
|
||||||
|
return m.stopFn(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
func (m *mockChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
||||||
m.placeholdersSent++
|
m.placeholdersSent++
|
||||||
|
|
@ -86,6 +99,101 @@ func newTestManager() *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
channels: make(map[string]Channel),
|
channels: make(map[string]Channel),
|
||||||
workers: make(map[string]*channelWorker),
|
workers: make(map[string]*channelWorker),
|
||||||
|
bus: bus.NewMessageBus(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartAll_AllChannelsFail_ReturnsJoinedError(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
errA := errors.New("channel-a start failed")
|
||||||
|
errB := errors.New("channel-b start failed")
|
||||||
|
|
||||||
|
m.channels["a"] = &mockChannel{
|
||||||
|
startFn: func(_ context.Context) error { return errA },
|
||||||
|
}
|
||||||
|
m.channels["b"] = &mockChannel{
|
||||||
|
startFn: func(_ context.Context) error { return errB },
|
||||||
|
}
|
||||||
|
|
||||||
|
err := m.StartAll(t.Context())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected StartAll to fail when all channels fail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "failed to start any enabled channels") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errA) {
|
||||||
|
t.Fatalf("expected error to wrap errA, got: %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errB) {
|
||||||
|
t.Fatalf("expected error to wrap errB, got: %v", err)
|
||||||
|
}
|
||||||
|
if len(m.workers) != 0 {
|
||||||
|
t.Fatalf("expected no workers on full startup failure, got %d", len(m.workers))
|
||||||
|
}
|
||||||
|
if m.dispatchTask != nil {
|
||||||
|
t.Fatal("expected dispatch task to be cleared on full startup failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartAll_PartialFailure_StartsSuccessfulWorkers(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
errBad := errors.New("bad channel start failed")
|
||||||
|
processed := make(chan struct{}, 1)
|
||||||
|
|
||||||
|
m.channels["good"] = &mockChannel{
|
||||||
|
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if msg.Channel == "good" {
|
||||||
|
select {
|
||||||
|
case processed <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
m.channels["bad"] = &mockChannel{
|
||||||
|
startFn: func(_ context.Context) error { return errBad },
|
||||||
|
}
|
||||||
|
|
||||||
|
err := m.StartAll(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected StartAll to succeed with partial channel failures, got: %v", err)
|
||||||
|
}
|
||||||
|
if len(m.workers) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 active worker, got %d", len(m.workers))
|
||||||
|
}
|
||||||
|
if _, ok := m.workers["good"]; !ok {
|
||||||
|
t.Fatal("expected worker for successful channel 'good'")
|
||||||
|
}
|
||||||
|
if _, ok := m.workers["bad"]; ok {
|
||||||
|
t.Fatal("did not expect worker for failed channel 'bad'")
|
||||||
|
}
|
||||||
|
if m.dispatchTask == nil {
|
||||||
|
t.Fatal("expected dispatch task to run when at least one channel starts")
|
||||||
|
}
|
||||||
|
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
if err := m.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
|
Channel: "good",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "hello",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("PublishOutbound() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-processed:
|
||||||
|
// worker processed outbound message as expected
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("expected successful channel worker to process outbound message")
|
||||||
|
}
|
||||||
|
|
||||||
|
stopCtx, stopCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer stopCancel()
|
||||||
|
if err := m.StopAll(stopCtx); err != nil {
|
||||||
|
t.Fatalf("StopAll() error = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -377,9 +378,39 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag
|
||||||
}
|
}
|
||||||
_, err = c.bot.EditMessageText(ctx, editMsg)
|
_, err = c.bot.EditMessageText(ctx, editMsg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// If it failed because it was already modified (likely from a previous
|
||||||
|
// attempt that timed out on our end but landed on Telegram), we treat
|
||||||
|
// it as success to prevent the Manager from sending a duplicate message.
|
||||||
|
if strings.Contains(err.Error(), "message is not modified") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fallback to plain text if the error looks like a parsing failure (Bad Request).
|
||||||
|
// Network errors or timeouts should NOT trigger a retry with different content.
|
||||||
|
if strings.Contains(err.Error(), "Bad Request") {
|
||||||
logParseFailed(err, useMarkdownV2)
|
logParseFailed(err, useMarkdownV2)
|
||||||
_, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content))
|
_, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "message is not modified") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if isPostConnectError(err) {
|
||||||
|
logger.WarnCF(
|
||||||
|
"telegram",
|
||||||
|
"EditMessage likely landed but result is unknown; swallowing error to prevent duplicate",
|
||||||
|
map[string]any{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"mid": mid,
|
||||||
|
"error": err.Error(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return nil // Swallow to prevent Manager fallback to a new SendMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -1134,6 +1165,30 @@ func cryptoRandInt() int {
|
||||||
return int(binary.BigEndian.Uint32(b[:])) | 1 // ensure non-zero
|
return int(binary.BigEndian.Uint32(b[:])) | 1 // ensure non-zero
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isPostConnectError identifies network errors that likely occurred after
|
||||||
|
// the request was transmitted to Telegram (e.g. dropped connection while
|
||||||
|
// waiting for response). Swallowing these for edits prevents duplicate
|
||||||
|
// fallbacks, at the small risk of leaving a stale placeholder if the
|
||||||
|
// edit never actually reached the server.
|
||||||
|
func isPostConnectError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context errors (timeout/canceled) are too broad; they can be triggered
|
||||||
|
// locally before any data is sent. Never swallow them.
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
// Narrowly target connection dropouts where the request likely landed.
|
||||||
|
return strings.Contains(msg, "connection reset by peer") ||
|
||||||
|
strings.Contains(msg, "unexpected eof") ||
|
||||||
|
strings.Contains(msg, "connection closed by foreign host") ||
|
||||||
|
strings.Contains(msg, "broken pipe")
|
||||||
|
}
|
||||||
|
|
||||||
// VoiceCapabilities returns the voice capabilities of the channel.
|
// VoiceCapabilities returns the voice capabilities of the channel.
|
||||||
func (c *TelegramChannel) VoiceCapabilities() channels.VoiceCapabilities {
|
func (c *TelegramChannel) VoiceCapabilities() channels.VoiceCapabilities {
|
||||||
return channels.VoiceCapabilities{ASR: true, TTS: true}
|
return channels.VoiceCapabilities{ASR: true, TTS: true}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -246,6 +247,8 @@ type AgentDefaults struct {
|
||||||
SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
|
SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
|
||||||
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
|
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
|
||||||
SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker
|
SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker
|
||||||
|
ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"`
|
||||||
|
ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
|
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
|
||||||
|
|
@ -614,6 +617,8 @@ type ModelConfig struct {
|
||||||
// existing configs, the field is inferred during load: models with API keys
|
// existing configs, the field is inferred during load: models with API keys
|
||||||
// or the reserved "local-model" name are auto-enabled.
|
// or the reserved "local-model" name are auto-enabled.
|
||||||
Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
|
Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
|
||||||
|
// UserAgent is the user agent string to use for HTTP requests.
|
||||||
|
UserAgent string `json:"user_agent,omitempty" yaml:"-"`
|
||||||
|
|
||||||
// isVirtual marks this model as a virtual model generated from multi-key expansion.
|
// isVirtual marks this model as a virtual model generated from multi-key expansion.
|
||||||
// Virtual models should not be persisted to config files.
|
// Virtual models should not be persisted to config files.
|
||||||
|
|
@ -816,9 +821,26 @@ type MediaCleanupConfig struct {
|
||||||
|
|
||||||
type ReadFileToolConfig struct {
|
type ReadFileToolConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
MaxReadFileSize int `json:"max_read_file_size"`
|
MaxReadFileSize int `json:"max_read_file_size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ReadFileModeBytes = "bytes"
|
||||||
|
ReadFileModeLines = "lines"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c ReadFileToolConfig) EffectiveMode() string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(c.Mode)) {
|
||||||
|
case ReadFileModeLines:
|
||||||
|
return ReadFileModeLines
|
||||||
|
case "", ReadFileModeBytes:
|
||||||
|
return ReadFileModeBytes
|
||||||
|
default:
|
||||||
|
return ReadFileModeBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ToolsConfig struct {
|
type ToolsConfig struct {
|
||||||
AllowReadPaths []string `json:"allow_read_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
|
AllowReadPaths []string `json:"allow_read_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
|
||||||
AllowWritePaths []string `json:"allow_write_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
|
AllowWritePaths []string `json:"allow_write_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,13 @@ func TestDefaultConfig_WebTools(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig_ReadFileMode(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if cfg.Tools.ReadFile.EffectiveMode() != ReadFileModeBytes {
|
||||||
|
t.Fatalf("expected default read_file mode %q, got %q", ReadFileModeBytes, cfg.Tools.ReadFile.EffectiveMode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSaveConfig_FilePermissions(t *testing.T) {
|
func TestSaveConfig_FilePermissions(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
t.Skip("file permission bits are not enforced on Windows")
|
t.Skip("file permission bits are not enforced on Windows")
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,13 @@ func DefaultConfig() *Config {
|
||||||
APIBase: "https://api.deepseek.com/v1",
|
APIBase: "https://api.deepseek.com/v1",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Venice AI - https://venice.ai
|
||||||
|
{
|
||||||
|
ModelName: "venice-uncensored",
|
||||||
|
Model: "venice/venice-uncensored",
|
||||||
|
APIBase: "https://api.venice.ai/api/v1",
|
||||||
|
},
|
||||||
|
|
||||||
// Google Gemini - https://ai.google.dev/
|
// Google Gemini - https://ai.google.dev/
|
||||||
{
|
{
|
||||||
ModelName: "gemini-2.0-flash",
|
ModelName: "gemini-2.0-flash",
|
||||||
|
|
@ -335,6 +342,13 @@ func DefaultConfig() *Config {
|
||||||
APIBase: "http://localhost:8000/v1",
|
APIBase: "http://localhost:8000/v1",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// LM Studio (local) - http://localhost:1234
|
||||||
|
{
|
||||||
|
ModelName: "lmstudio-local",
|
||||||
|
Model: "lmstudio/openai/gpt-oss-20b",
|
||||||
|
APIBase: "http://localhost:1234/v1",
|
||||||
|
},
|
||||||
|
|
||||||
// Azure OpenAI - https://portal.azure.com
|
// Azure OpenAI - https://portal.azure.com
|
||||||
// model_name is a user-friendly alias; the model field's path after "azure/" is your deployment name
|
// model_name is a user-friendly alias; the model field's path after "azure/" is your deployment name
|
||||||
{
|
{
|
||||||
|
|
@ -473,6 +487,7 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
ReadFile: ReadFileToolConfig{
|
ReadFile: ReadFileToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
Mode: ReadFileModeBytes,
|
||||||
MaxReadFileSize: 64 * 1024, // 64KB
|
MaxReadFileSize: 64 * 1024, // 64KB
|
||||||
},
|
},
|
||||||
Spawn: ToolConfig{
|
Spawn: ToolConfig{
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,15 @@ package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var panicWriter io.WriteCloser
|
||||||
|
|
||||||
func InitPanic(filePath string) (func(), error) {
|
func InitPanic(filePath string) (func(), error) {
|
||||||
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create log directory: %w", err)
|
return nil, fmt.Errorf("failed to create log directory: %w", err)
|
||||||
|
|
@ -16,9 +19,28 @@ func InitPanic(filePath string) (func(), error) {
|
||||||
if writer == nil {
|
if writer == nil {
|
||||||
return nil, fmt.Errorf("failed to create log file: %s", filePath)
|
return nil, fmt.Errorf("failed to create log file: %s", filePath)
|
||||||
}
|
}
|
||||||
|
if panicWriter != nil {
|
||||||
|
_ = panicWriter.Close()
|
||||||
|
}
|
||||||
|
panicWriter = writer
|
||||||
return func() {
|
return func() {
|
||||||
defer writer.Close()
|
defer func() {
|
||||||
|
writer.Close()
|
||||||
|
panicWriter = nil
|
||||||
|
}()
|
||||||
if err := recover(); err != nil {
|
if err := recover(); err != nil {
|
||||||
|
RecoverPanicNoExit(err)
|
||||||
|
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RecoverPanicNoExit(err any) {
|
||||||
|
if panicWriter == nil {
|
||||||
|
Errorf("panicWriter is nil, should not happen")
|
||||||
|
return
|
||||||
|
}
|
||||||
now := time.Now().Format("2006-01-02 15:04:05")
|
now := time.Now().Format("2006-01-02 15:04:05")
|
||||||
stack := debug.Stack()
|
stack := debug.Stack()
|
||||||
logMsg := "\n\n====================\n[" + now + "] PANIC OCCURRED: " + fmt.Sprintf(
|
logMsg := "\n\n====================\n[" + now + "] PANIC OCCURRED: " + fmt.Sprintf(
|
||||||
|
|
@ -28,9 +50,5 @@ func InitPanic(filePath string) (func(), error) {
|
||||||
stack,
|
stack,
|
||||||
)
|
)
|
||||||
|
|
||||||
writer.Write([]byte(logMsg))
|
panicWriter.Write([]byte(logMsg))
|
||||||
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,15 +41,16 @@ type Provider struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
apiBase string
|
apiBase string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProvider creates a new Anthropic Messages API provider.
|
// NewProvider creates a new Anthropic Messages API provider.
|
||||||
func NewProvider(apiKey, apiBase string) *Provider {
|
func NewProvider(apiKey, apiBase, userAgent string) *Provider {
|
||||||
return NewProviderWithTimeout(apiKey, apiBase, 0)
|
return NewProviderWithTimeout(apiKey, apiBase, userAgent, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProviderWithTimeout creates a provider with custom request timeout.
|
// NewProviderWithTimeout creates a provider with custom request timeout.
|
||||||
func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provider {
|
func NewProviderWithTimeout(apiKey, apiBase, userAgent string, timeoutSeconds int) *Provider {
|
||||||
baseURL := normalizeBaseURL(apiBase)
|
baseURL := normalizeBaseURL(apiBase)
|
||||||
timeout := defaultRequestTimeout
|
timeout := defaultRequestTimeout
|
||||||
if timeoutSeconds > 0 {
|
if timeoutSeconds > 0 {
|
||||||
|
|
@ -59,6 +60,7 @@ func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provide
|
||||||
return &Provider{
|
return &Provider{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: baseURL,
|
apiBase: baseURL,
|
||||||
|
userAgent: userAgent,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
},
|
},
|
||||||
|
|
@ -105,6 +107,9 @@ func (p *Provider) Chat(
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("X-API-Key", p.apiKey) //nolint:canonicalheader // Anthropic API requires exact header name
|
req.Header.Set("X-API-Key", p.apiKey) //nolint:canonicalheader // Anthropic API requires exact header name
|
||||||
req.Header.Set("Anthropic-Version", defaultAPIVersion)
|
req.Header.Set("Anthropic-Version", defaultAPIVersion)
|
||||||
|
if p.userAgent != "" {
|
||||||
|
req.Header.Set("User-Agent", p.userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
// Execute request
|
// Execute request
|
||||||
resp, err := p.httpClient.Do(req)
|
resp, err := p.httpClient.Do(req)
|
||||||
|
|
|
||||||
|
|
@ -411,7 +411,7 @@ func TestNormalizeBaseURL(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewProvider(t *testing.T) {
|
func TestNewProvider(t *testing.T) {
|
||||||
provider := NewProvider("test-key", "https://api.example.com")
|
provider := NewProvider("test-key", "https://api.example.com", "")
|
||||||
if provider == nil {
|
if provider == nil {
|
||||||
t.Fatal("NewProvider() returned nil")
|
t.Fatal("NewProvider() returned nil")
|
||||||
}
|
}
|
||||||
|
|
@ -424,7 +424,7 @@ func TestNewProvider(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetDefaultModel(t *testing.T) {
|
func TestGetDefaultModel(t *testing.T) {
|
||||||
provider := NewProvider("test-key", "")
|
provider := NewProvider("test-key", "", "")
|
||||||
got := provider.GetDefaultModel()
|
got := provider.GetDefaultModel()
|
||||||
expected := "claude-sonnet-4.6"
|
expected := "claude-sonnet-4.6"
|
||||||
if got != expected {
|
if got != expected {
|
||||||
|
|
@ -743,7 +743,7 @@ func TestProviderChatErrors(t *testing.T) {
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Create provider using constructor to ensure proper initialization
|
// Create provider using constructor to ensure proper initialization
|
||||||
provider := NewProvider(tt.apiKey, "https://api.example.com")
|
provider := NewProvider(tt.apiKey, "https://api.example.com", "")
|
||||||
|
|
||||||
_, err := provider.Chat(context.Background(), tt.messages, nil, "test-model", nil)
|
_, err := provider.Chat(context.Background(), tt.messages, nil, "test-model", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ type Provider struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
apiBase string
|
apiBase string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option configures the Azure Provider.
|
// Option configures the Azure Provider.
|
||||||
|
|
@ -50,11 +51,19 @@ func WithRequestTimeout(timeout time.Duration) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithUserAgent sets the User-Agent header for requests.
|
||||||
|
func WithUserAgent(userAgent string) Option {
|
||||||
|
return func(p *Provider) {
|
||||||
|
p.userAgent = userAgent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewProvider creates a new Azure OpenAI provider.
|
// NewProvider creates a new Azure OpenAI provider.
|
||||||
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
|
func NewProvider(apiKey, apiBase, proxy, userAgent string, opts ...Option) *Provider {
|
||||||
p := &Provider{
|
p := &Provider{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: strings.TrimRight(apiBase, "/"),
|
apiBase: strings.TrimRight(apiBase, "/"),
|
||||||
|
userAgent: userAgent,
|
||||||
httpClient: common.NewHTTPClient(proxy),
|
httpClient: common.NewHTTPClient(proxy),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,9 +77,9 @@ func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProviderWithTimeout creates a new Azure OpenAI provider with a custom request timeout in seconds.
|
// NewProviderWithTimeout creates a new Azure OpenAI provider with a custom request timeout in seconds.
|
||||||
func NewProviderWithTimeout(apiKey, apiBase, proxy string, requestTimeoutSeconds int) *Provider {
|
func NewProviderWithTimeout(apiKey, apiBase, proxy, userAgent string, requestTimeoutSeconds int) *Provider {
|
||||||
return NewProvider(
|
return NewProvider(
|
||||||
apiKey, apiBase, proxy,
|
apiKey, apiBase, proxy, userAgent,
|
||||||
WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -141,6 +150,9 @@ func (p *Provider) Chat(
|
||||||
if p.apiKey != "" {
|
if p.apiKey != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
}
|
}
|
||||||
|
if p.userAgent != "" {
|
||||||
|
req.Header.Set("User-Agent", p.userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := p.httpClient.Do(req)
|
resp, err := p.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ func TestProviderChat_AzureURLConstruction(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-gpt5-deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-gpt5-deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -69,7 +69,7 @@ func TestProviderChat_AzureAuthHeader(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-azure-key", server.URL, "")
|
p := NewProvider("test-azure-key", server.URL, "", "")
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -92,7 +92,7 @@ func TestProviderChat_AzureRequestBodyContainsModel(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -112,7 +112,7 @@ func TestProviderChat_AzureUsesMaxOutputTokens(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
_, err := p.Chat(
|
_, err := p.Chat(
|
||||||
t.Context(),
|
t.Context(),
|
||||||
[]Message{{Role: "user", Content: "hi"}},
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
|
@ -144,7 +144,7 @@ func TestProviderChat_AzureStoreIsFalse(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -161,7 +161,7 @@ func TestProviderChat_AzureHTTPError(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("bad-key", server.URL, "")
|
p := NewProvider("bad-key", server.URL, "", "")
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
|
|
@ -176,7 +176,7 @@ func TestProviderChat_AzureRateLimitError(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 429, got nil")
|
t.Fatal("expected error for 429, got nil")
|
||||||
|
|
@ -194,7 +194,7 @@ func TestProviderChat_AzureServerError(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 500, got nil")
|
t.Fatal("expected error for 500, got nil")
|
||||||
|
|
@ -229,7 +229,7 @@ func TestProviderChat_AzureParseTextOutput(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -270,7 +270,7 @@ func TestProviderChat_AzureParseToolCalls(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "weather?"}}, nil, "deployment", nil)
|
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "weather?"}}, nil, "deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -287,7 +287,7 @@ func TestProviderChat_AzureParseToolCalls(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvider_AzureEmptyAPIBase(t *testing.T) {
|
func TestProvider_AzureEmptyAPIBase(t *testing.T) {
|
||||||
p := NewProvider("test-key", "", "")
|
p := NewProvider("test-key", "", "", "")
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for empty API base")
|
t.Fatal("expected error for empty API base")
|
||||||
|
|
@ -295,21 +295,21 @@ func TestProvider_AzureEmptyAPIBase(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvider_AzureRequestTimeoutDefault(t *testing.T) {
|
func TestProvider_AzureRequestTimeoutDefault(t *testing.T) {
|
||||||
p := NewProvider("test-key", "https://example.com", "")
|
p := NewProvider("test-key", "https://example.com", "", "")
|
||||||
if p.httpClient.Timeout != defaultRequestTimeout {
|
if p.httpClient.Timeout != defaultRequestTimeout {
|
||||||
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
|
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvider_AzureRequestTimeoutOverride(t *testing.T) {
|
func TestProvider_AzureRequestTimeoutOverride(t *testing.T) {
|
||||||
p := NewProvider("test-key", "https://example.com", "", WithRequestTimeout(300*time.Second))
|
p := NewProvider("test-key", "https://example.com", "", "", WithRequestTimeout(300*time.Second))
|
||||||
if p.httpClient.Timeout != 300*time.Second {
|
if p.httpClient.Timeout != 300*time.Second {
|
||||||
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second)
|
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvider_AzureNewProviderWithTimeout(t *testing.T) {
|
func TestProvider_AzureNewProviderWithTimeout(t *testing.T) {
|
||||||
p := NewProviderWithTimeout("test-key", "https://example.com", "", 180)
|
p := NewProviderWithTimeout("test-key", "https://example.com", "", "", 180)
|
||||||
if p.httpClient.Timeout != 180*time.Second {
|
if p.httpClient.Timeout != 180*time.Second {
|
||||||
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 180*time.Second)
|
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 180*time.Second)
|
||||||
}
|
}
|
||||||
|
|
@ -343,7 +343,7 @@ func TestProviderChat_AzureNativeWebSearchInjection(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
|
|
||||||
// With native_search=true: user-defined web_search should be replaced by built-in
|
// With native_search=true: user-defined web_search should be replaced by built-in
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment",
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment",
|
||||||
|
|
@ -393,7 +393,7 @@ func TestProviderChat_AzureNoNativeWebSearch(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
|
|
||||||
// Without native_search: user-defined web_search should be kept as-is
|
// Without native_search: user-defined web_search should be kept as-is
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment", nil)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ type protocolMeta struct {
|
||||||
|
|
||||||
var protocolMetaByName = map[string]protocolMeta{
|
var protocolMetaByName = map[string]protocolMeta{
|
||||||
"openai": {defaultAPIBase: "https://api.openai.com/v1"},
|
"openai": {defaultAPIBase: "https://api.openai.com/v1"},
|
||||||
|
"venice": {defaultAPIBase: "https://api.venice.ai/api/v1"},
|
||||||
"openrouter": {defaultAPIBase: "https://openrouter.ai/api/v1"},
|
"openrouter": {defaultAPIBase: "https://openrouter.ai/api/v1"},
|
||||||
"litellm": {defaultAPIBase: "http://localhost:4000/v1"},
|
"litellm": {defaultAPIBase: "http://localhost:4000/v1"},
|
||||||
"lmstudio": {defaultAPIBase: "http://localhost:1234/v1", emptyAPIKeyAllowed: true},
|
"lmstudio": {defaultAPIBase: "http://localhost:1234/v1", emptyAPIKeyAllowed: true},
|
||||||
|
|
@ -128,6 +129,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
|
|
||||||
protocol, modelID := ExtractProtocol(cfg.Model)
|
protocol, modelID := ExtractProtocol(cfg.Model)
|
||||||
|
|
||||||
|
userAgent := cfg.UserAgent
|
||||||
|
if userAgent == "" {
|
||||||
|
userAgent = fmt.Sprintf("PicoClaw/%s", config.Version)
|
||||||
|
}
|
||||||
|
|
||||||
switch protocol {
|
switch protocol {
|
||||||
case "openai":
|
case "openai":
|
||||||
// OpenAI with OAuth/token auth (Codex-style)
|
// OpenAI with OAuth/token auth (Codex-style)
|
||||||
|
|
@ -151,6 +157,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
cfg.ExtraBody,
|
cfg.ExtraBody,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
@ -170,6 +177,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
cfg.APIKey(),
|
cfg.APIKey(),
|
||||||
cfg.APIBase,
|
cfg.APIBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
|
|
@ -209,7 +217,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
}
|
}
|
||||||
return provider, modelID, nil
|
return provider, modelID, nil
|
||||||
|
|
||||||
case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia", "venice",
|
||||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||||
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
|
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
|
||||||
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
|
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
|
||||||
|
|
@ -227,6 +235,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
cfg.ExtraBody,
|
cfg.ExtraBody,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
@ -252,6 +261,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
extraBody,
|
extraBody,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
@ -278,6 +288,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
cfg.ExtraBody,
|
cfg.ExtraBody,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
@ -294,6 +305,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
return anthropicmessages.NewProviderWithTimeout(
|
return anthropicmessages.NewProviderWithTimeout(
|
||||||
cfg.APIKey(),
|
cfg.APIKey(),
|
||||||
apiBase,
|
apiBase,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
|
|
@ -309,6 +321,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
return anthropicmessages.NewProviderWithTimeout(
|
return anthropicmessages.NewProviderWithTimeout(
|
||||||
cfg.APIKey(),
|
cfg.APIKey(),
|
||||||
apiBase,
|
apiBase,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
|
||||||
protocol string
|
protocol string
|
||||||
}{
|
}{
|
||||||
{"openai", "openai"},
|
{"openai", "openai"},
|
||||||
|
{"venice", "venice"},
|
||||||
{"groq", "groq"},
|
{"groq", "groq"},
|
||||||
{"novita", "novita"},
|
{"novita", "novita"},
|
||||||
{"openrouter", "openrouter"},
|
{"openrouter", "openrouter"},
|
||||||
|
|
@ -160,6 +161,12 @@ func TestGetDefaultAPIBase_LMStudio(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetDefaultAPIBase_Venice(t *testing.T) {
|
||||||
|
if got := getDefaultAPIBase("venice"); got != "https://api.venice.ai/api/v1" {
|
||||||
|
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "venice", got, "https://api.venice.ai/api/v1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
|
func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-litellm",
|
ModelName: "test-litellm",
|
||||||
|
|
@ -362,6 +369,28 @@ func TestCreateProviderFromConfig_Mimo(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_Venice(t *testing.T) {
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-venice",
|
||||||
|
Model: "venice/venice-uncensored",
|
||||||
|
}
|
||||||
|
cfg.SetAPIKey("test-key")
|
||||||
|
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if provider == nil {
|
||||||
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||||
|
}
|
||||||
|
if modelID != "venice-uncensored" {
|
||||||
|
t.Errorf("modelID = %q, want %q", modelID, "venice-uncensored")
|
||||||
|
}
|
||||||
|
if _, ok := provider.(*HTTPProvider); !ok {
|
||||||
|
t.Fatalf("expected *HTTPProvider, got %T", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetDefaultAPIBase_Mimo(t *testing.T) {
|
func TestGetDefaultAPIBase_Mimo(t *testing.T) {
|
||||||
if got := getDefaultAPIBase("mimo"); got != "https://api.xiaomimimo.com/v1" {
|
if got := getDefaultAPIBase("mimo"); got != "https://api.xiaomimimo.com/v1" {
|
||||||
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "mimo", got, "https://api.xiaomimimo.com/v1")
|
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "mimo", got, "https://api.xiaomimimo.com/v1")
|
||||||
|
|
@ -817,6 +846,107 @@ func TestCreateProviderFromConfig_MinimaxPreservesUserExtraBody(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openaiCompatResponse is the JSON response used by OpenAI-compatible providers.
|
||||||
|
const openaiCompatResponse = `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`
|
||||||
|
|
||||||
|
// anthropicResponse is the JSON response used by Anthropic providers.
|
||||||
|
const anthropicResponse = `{"content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","model":"claude-sonnet-4-20250514","usage":{"input_tokens":10,"output_tokens":5}}`
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_UserAgent(t *testing.T) {
|
||||||
|
defaultUA := "PicoClaw/" + config.Version
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
model string
|
||||||
|
userAgent string
|
||||||
|
apiKey string
|
||||||
|
response string
|
||||||
|
wantUA string
|
||||||
|
chatOpts map[string]any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "openai default user agent",
|
||||||
|
model: "openai/gpt-4o",
|
||||||
|
apiKey: "test-key",
|
||||||
|
response: openaiCompatResponse,
|
||||||
|
wantUA: defaultUA,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "openai custom user agent",
|
||||||
|
model: "openai/gpt-4o",
|
||||||
|
apiKey: "test-key",
|
||||||
|
userAgent: "MyAgent/1.2.3",
|
||||||
|
response: openaiCompatResponse,
|
||||||
|
wantUA: "MyAgent/1.2.3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "anthropic default user agent",
|
||||||
|
model: "anthropic/claude-sonnet-4-20250514",
|
||||||
|
apiKey: "test-key",
|
||||||
|
response: anthropicResponse,
|
||||||
|
wantUA: defaultUA,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "anthropic-messages default user agent",
|
||||||
|
model: "anthropic-messages/claude-sonnet-4-20250514",
|
||||||
|
apiKey: "test-key",
|
||||||
|
response: anthropicResponse,
|
||||||
|
wantUA: defaultUA,
|
||||||
|
chatOpts: map[string]any{"max_tokens": 1024},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "azure default user agent",
|
||||||
|
model: "azure/my-deployment",
|
||||||
|
apiKey: "test-azure-key",
|
||||||
|
response: openaiCompatResponse,
|
||||||
|
wantUA: defaultUA,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var receivedUA string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
receivedUA = r.Header.Get("User-Agent")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(tt.response))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-ua-" + tt.name,
|
||||||
|
Model: tt.model,
|
||||||
|
APIBase: server.URL,
|
||||||
|
UserAgent: tt.userAgent,
|
||||||
|
}
|
||||||
|
cfg.SetAPIKey(tt.apiKey)
|
||||||
|
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if provider == nil {
|
||||||
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = provider.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
modelID,
|
||||||
|
tt.chatOpts,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if receivedUA != tt.wantUA {
|
||||||
|
t.Errorf("User-Agent = %q, want %q", receivedUA, tt.wantUA)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateProviderFromConfig_Bedrock(t *testing.T) {
|
func TestCreateProviderFromConfig_Bedrock(t *testing.T) {
|
||||||
// Set dummy AWS env vars to make test deterministic
|
// Set dummy AWS env vars to make test deterministic
|
||||||
t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
|
t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,24 @@ import (
|
||||||
// FallbackChain orchestrates model fallback across multiple candidates.
|
// FallbackChain orchestrates model fallback across multiple candidates.
|
||||||
type FallbackChain struct {
|
type FallbackChain struct {
|
||||||
cooldown *CooldownTracker
|
cooldown *CooldownTracker
|
||||||
|
rl *RateLimiterRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
// FallbackCandidate represents one model/provider to try.
|
// FallbackCandidate represents one model/provider to try.
|
||||||
type FallbackCandidate struct {
|
type FallbackCandidate struct {
|
||||||
Provider string
|
Provider string
|
||||||
Model string
|
Model string
|
||||||
|
RPM int // requests per minute; 0 means unrestricted
|
||||||
|
IdentityKey string // optional stable config identity for cooldown/rate limiting
|
||||||
|
}
|
||||||
|
|
||||||
|
// StableKey returns the candidate's config-level identity when available,
|
||||||
|
// otherwise it falls back to the runtime provider/model key.
|
||||||
|
func (c FallbackCandidate) StableKey() string {
|
||||||
|
if key := strings.TrimSpace(c.IdentityKey); key != "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return ModelKey(c.Provider, c.Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FallbackResult contains the successful response and metadata about all attempts.
|
// FallbackResult contains the successful response and metadata about all attempts.
|
||||||
|
|
@ -36,9 +48,10 @@ type FallbackAttempt struct {
|
||||||
Skipped bool // true if skipped due to cooldown
|
Skipped bool // true if skipped due to cooldown
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFallbackChain creates a new fallback chain with the given cooldown tracker.
|
// NewFallbackChain creates a new fallback chain with the given cooldown tracker
|
||||||
func NewFallbackChain(cooldown *CooldownTracker) *FallbackChain {
|
// and rate limiter registry.
|
||||||
return &FallbackChain{cooldown: cooldown}
|
func NewFallbackChain(cooldown *CooldownTracker, rl *RateLimiterRegistry) *FallbackChain {
|
||||||
|
return &FallbackChain{cooldown: cooldown, rl: rl}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveCandidates parses model config into a deduplicated candidate list.
|
// ResolveCandidates parses model config into a deduplicated candidate list.
|
||||||
|
|
@ -117,9 +130,9 @@ func (fc *FallbackChain) Execute(
|
||||||
return nil, context.Canceled
|
return nil, context.Canceled
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cooldown (per provider/model, not just provider).
|
// Check cooldown per stable candidate identity, not just provider/model.
|
||||||
// This allows multi-key failover where different keys use different model names.
|
// This allows aliases and multi-key configs to fail over independently.
|
||||||
cooldownKey := ModelKey(candidate.Provider, candidate.Model)
|
cooldownKey := candidate.StableKey()
|
||||||
if !fc.cooldown.IsAvailable(cooldownKey) {
|
if !fc.cooldown.IsAvailable(cooldownKey) {
|
||||||
remaining := fc.cooldown.CooldownRemaining(cooldownKey)
|
remaining := fc.cooldown.CooldownRemaining(cooldownKey)
|
||||||
result.Attempts = append(result.Attempts, FallbackAttempt{
|
result.Attempts = append(result.Attempts, FallbackAttempt{
|
||||||
|
|
@ -136,6 +149,33 @@ func (fc *FallbackChain) Execute(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce per-candidate rate limit before calling the provider.
|
||||||
|
// If this candidate is locally saturated, try other candidates first.
|
||||||
|
if fc.rl != nil {
|
||||||
|
if !fc.rl.TryAcquire(cooldownKey) {
|
||||||
|
if i < len(candidates)-1 {
|
||||||
|
result.Attempts = append(result.Attempts, FallbackAttempt{
|
||||||
|
Provider: candidate.Provider,
|
||||||
|
Model: candidate.Model,
|
||||||
|
Skipped: true,
|
||||||
|
Reason: FailoverRateLimit,
|
||||||
|
Error: fmt.Errorf("%s waiting for local rate limit token", cooldownKey),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if waitErr := fc.rl.Wait(ctx, cooldownKey); waitErr != nil {
|
||||||
|
result.Attempts = append(result.Attempts, FallbackAttempt{
|
||||||
|
Provider: candidate.Provider,
|
||||||
|
Model: candidate.Model,
|
||||||
|
Skipped: true,
|
||||||
|
Reason: FailoverRateLimit,
|
||||||
|
Error: waitErr,
|
||||||
|
})
|
||||||
|
return nil, waitErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Execute the run function.
|
// Execute the run function.
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, err := run(ctx, candidate.Provider, candidate.Model)
|
resp, err := run(ctx, candidate.Provider, candidate.Model)
|
||||||
|
|
@ -229,6 +269,34 @@ func (fc *FallbackChain) ExecuteImage(
|
||||||
return nil, context.Canceled
|
return nil, context.Canceled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce per-candidate rate limit before calling the provider.
|
||||||
|
// If this candidate is locally saturated, try other candidates first.
|
||||||
|
imageKey := candidate.StableKey()
|
||||||
|
if fc.rl != nil {
|
||||||
|
if !fc.rl.TryAcquire(imageKey) {
|
||||||
|
if i < len(candidates)-1 {
|
||||||
|
result.Attempts = append(result.Attempts, FallbackAttempt{
|
||||||
|
Provider: candidate.Provider,
|
||||||
|
Model: candidate.Model,
|
||||||
|
Skipped: true,
|
||||||
|
Reason: FailoverRateLimit,
|
||||||
|
Error: fmt.Errorf("%s waiting for local rate limit token", imageKey),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if waitErr := fc.rl.Wait(ctx, imageKey); waitErr != nil {
|
||||||
|
result.Attempts = append(result.Attempts, FallbackAttempt{
|
||||||
|
Provider: candidate.Provider,
|
||||||
|
Model: candidate.Model,
|
||||||
|
Skipped: true,
|
||||||
|
Reason: FailoverRateLimit,
|
||||||
|
Error: waitErr,
|
||||||
|
})
|
||||||
|
return nil, waitErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, err := run(ctx, candidate.Provider, candidate.Model)
|
resp, err := run(ctx, candidate.Provider, candidate.Model)
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ func TestMultiKeyFailover(t *testing.T) {
|
||||||
|
|
||||||
// Create fallback chain
|
// Create fallback chain
|
||||||
cooldown := NewCooldownTracker()
|
cooldown := NewCooldownTracker()
|
||||||
chain := NewFallbackChain(cooldown)
|
chain := NewFallbackChain(cooldown, nil)
|
||||||
|
|
||||||
// Mock run function: first call fails with 429, second succeeds
|
// Mock run function: first call fails with 429, second succeeds
|
||||||
callCount := 0
|
callCount := 0
|
||||||
|
|
@ -82,7 +82,7 @@ func TestMultiKeyFailoverAllFail(t *testing.T) {
|
||||||
candidates := ResolveCandidates(cfg, "zhipu")
|
candidates := ResolveCandidates(cfg, "zhipu")
|
||||||
|
|
||||||
cooldown := NewCooldownTracker()
|
cooldown := NewCooldownTracker()
|
||||||
chain := NewFallbackChain(cooldown)
|
chain := NewFallbackChain(cooldown, nil)
|
||||||
|
|
||||||
// Mock run function: all calls fail with rate limit
|
// Mock run function: all calls fail with rate limit
|
||||||
callCount := 0
|
callCount := 0
|
||||||
|
|
@ -127,7 +127,7 @@ func TestMultiKeyFailoverCooldown(t *testing.T) {
|
||||||
candidates := ResolveCandidates(cfg, "zhipu")
|
candidates := ResolveCandidates(cfg, "zhipu")
|
||||||
|
|
||||||
cooldown := NewCooldownTracker()
|
cooldown := NewCooldownTracker()
|
||||||
chain := NewFallbackChain(cooldown)
|
chain := NewFallbackChain(cooldown, nil)
|
||||||
|
|
||||||
// Put the first model in cooldown (using ModelKey now, not just provider)
|
// Put the first model in cooldown (using ModelKey now, not just provider)
|
||||||
cooldownKey := ModelKey(candidates[0].Provider, candidates[0].Model)
|
cooldownKey := ModelKey(candidates[0].Provider, candidates[0].Model)
|
||||||
|
|
@ -183,7 +183,7 @@ func TestMultiKeyFailoverWithFormatError(t *testing.T) {
|
||||||
candidates := ResolveCandidates(cfg, "zhipu")
|
candidates := ResolveCandidates(cfg, "zhipu")
|
||||||
|
|
||||||
cooldown := NewCooldownTracker()
|
cooldown := NewCooldownTracker()
|
||||||
chain := NewFallbackChain(cooldown)
|
chain := NewFallbackChain(cooldown, nil)
|
||||||
|
|
||||||
// Mock run function: first call fails with format error (bad request)
|
// Mock run function: first call fails with format error (bad request)
|
||||||
callCount := 0
|
callCount := 0
|
||||||
|
|
@ -263,7 +263,7 @@ func TestMultiKeyWithModelFallback(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
cooldown := NewCooldownTracker()
|
cooldown := NewCooldownTracker()
|
||||||
chain := NewFallbackChain(cooldown)
|
chain := NewFallbackChain(cooldown, nil)
|
||||||
|
|
||||||
// Mock run function: first two fail, third succeeds (model fallback)
|
// Mock run function: first two fail, third succeeds (model fallback)
|
||||||
callCount := 0
|
callCount := 0
|
||||||
|
|
@ -337,7 +337,7 @@ func TestMultiKeyFailoverMixedErrors(t *testing.T) {
|
||||||
candidates := ResolveCandidates(cfg, "zhipu")
|
candidates := ResolveCandidates(cfg, "zhipu")
|
||||||
|
|
||||||
cooldown := NewCooldownTracker()
|
cooldown := NewCooldownTracker()
|
||||||
chain := NewFallbackChain(cooldown)
|
chain := NewFallbackChain(cooldown, nil)
|
||||||
|
|
||||||
// Mock run function: different errors for each key
|
// Mock run function: different errors for each key
|
||||||
callCount := 0
|
callCount := 0
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ func successRun(content string) func(ctx context.Context, provider, model string
|
||||||
|
|
||||||
func TestFallback_SingleCandidate_Success(t *testing.T) {
|
func TestFallback_SingleCandidate_Success(t *testing.T) {
|
||||||
ct := NewCooldownTracker()
|
ct := NewCooldownTracker()
|
||||||
fc := NewFallbackChain(ct)
|
fc := NewFallbackChain(ct, nil)
|
||||||
|
|
||||||
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
|
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
|
||||||
result, err := fc.Execute(context.Background(), candidates, successRun("hello"))
|
result, err := fc.Execute(context.Background(), candidates, successRun("hello"))
|
||||||
|
|
@ -36,7 +36,7 @@ func TestFallback_SingleCandidate_Success(t *testing.T) {
|
||||||
|
|
||||||
func TestFallback_SecondCandidateSuccess(t *testing.T) {
|
func TestFallback_SecondCandidateSuccess(t *testing.T) {
|
||||||
ct := NewCooldownTracker()
|
ct := NewCooldownTracker()
|
||||||
fc := NewFallbackChain(ct)
|
fc := NewFallbackChain(ct, nil)
|
||||||
|
|
||||||
candidates := []FallbackCandidate{
|
candidates := []FallbackCandidate{
|
||||||
makeCandidate("openai", "gpt-4"),
|
makeCandidate("openai", "gpt-4"),
|
||||||
|
|
@ -69,7 +69,7 @@ func TestFallback_SecondCandidateSuccess(t *testing.T) {
|
||||||
|
|
||||||
func TestFallback_AllFail(t *testing.T) {
|
func TestFallback_AllFail(t *testing.T) {
|
||||||
ct := NewCooldownTracker()
|
ct := NewCooldownTracker()
|
||||||
fc := NewFallbackChain(ct)
|
fc := NewFallbackChain(ct, nil)
|
||||||
|
|
||||||
candidates := []FallbackCandidate{
|
candidates := []FallbackCandidate{
|
||||||
makeCandidate("openai", "gpt-4"),
|
makeCandidate("openai", "gpt-4"),
|
||||||
|
|
@ -96,7 +96,7 @@ func TestFallback_AllFail(t *testing.T) {
|
||||||
|
|
||||||
func TestFallback_ContextCanceled(t *testing.T) {
|
func TestFallback_ContextCanceled(t *testing.T) {
|
||||||
ct := NewCooldownTracker()
|
ct := NewCooldownTracker()
|
||||||
fc := NewFallbackChain(ct)
|
fc := NewFallbackChain(ct, nil)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
candidates := []FallbackCandidate{
|
candidates := []FallbackCandidate{
|
||||||
|
|
@ -123,7 +123,7 @@ func TestFallback_ContextCanceled(t *testing.T) {
|
||||||
|
|
||||||
func TestFallback_NonRetriableError(t *testing.T) {
|
func TestFallback_NonRetriableError(t *testing.T) {
|
||||||
ct := NewCooldownTracker()
|
ct := NewCooldownTracker()
|
||||||
fc := NewFallbackChain(ct)
|
fc := NewFallbackChain(ct, nil)
|
||||||
|
|
||||||
candidates := []FallbackCandidate{
|
candidates := []FallbackCandidate{
|
||||||
makeCandidate("openai", "gpt-4"),
|
makeCandidate("openai", "gpt-4"),
|
||||||
|
|
@ -155,7 +155,7 @@ func TestFallback_NonRetriableError(t *testing.T) {
|
||||||
func TestFallback_CooldownSkip(t *testing.T) {
|
func TestFallback_CooldownSkip(t *testing.T) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
ct, _ := newTestTracker(now)
|
ct, _ := newTestTracker(now)
|
||||||
fc := NewFallbackChain(ct)
|
fc := NewFallbackChain(ct, nil)
|
||||||
|
|
||||||
// Put openai/gpt-4 in cooldown (using ModelKey now)
|
// Put openai/gpt-4 in cooldown (using ModelKey now)
|
||||||
ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit)
|
ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit)
|
||||||
|
|
@ -193,7 +193,7 @@ func TestFallback_CooldownSkip(t *testing.T) {
|
||||||
|
|
||||||
func TestFallback_AllInCooldown(t *testing.T) {
|
func TestFallback_AllInCooldown(t *testing.T) {
|
||||||
ct := NewCooldownTracker()
|
ct := NewCooldownTracker()
|
||||||
fc := NewFallbackChain(ct)
|
fc := NewFallbackChain(ct, nil)
|
||||||
|
|
||||||
// Put all models in cooldown (using ModelKey now)
|
// Put all models in cooldown (using ModelKey now)
|
||||||
ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit)
|
ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit)
|
||||||
|
|
@ -221,7 +221,7 @@ func TestFallback_AllInCooldown(t *testing.T) {
|
||||||
|
|
||||||
func TestFallback_NoCandidates(t *testing.T) {
|
func TestFallback_NoCandidates(t *testing.T) {
|
||||||
ct := NewCooldownTracker()
|
ct := NewCooldownTracker()
|
||||||
fc := NewFallbackChain(ct)
|
fc := NewFallbackChain(ct, nil)
|
||||||
|
|
||||||
_, err := fc.Execute(context.Background(), nil, successRun("ok"))
|
_, err := fc.Execute(context.Background(), nil, successRun("ok"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -232,7 +232,7 @@ func TestFallback_NoCandidates(t *testing.T) {
|
||||||
func TestFallback_EmptyFallbacks(t *testing.T) {
|
func TestFallback_EmptyFallbacks(t *testing.T) {
|
||||||
// Single primary, no fallbacks: should work like direct call
|
// Single primary, no fallbacks: should work like direct call
|
||||||
ct := NewCooldownTracker()
|
ct := NewCooldownTracker()
|
||||||
fc := NewFallbackChain(ct)
|
fc := NewFallbackChain(ct, nil)
|
||||||
|
|
||||||
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
|
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
|
||||||
result, err := fc.Execute(context.Background(), candidates, successRun("ok"))
|
result, err := fc.Execute(context.Background(), candidates, successRun("ok"))
|
||||||
|
|
@ -246,7 +246,7 @@ func TestFallback_EmptyFallbacks(t *testing.T) {
|
||||||
|
|
||||||
func TestFallback_UnclassifiedError(t *testing.T) {
|
func TestFallback_UnclassifiedError(t *testing.T) {
|
||||||
ct := NewCooldownTracker()
|
ct := NewCooldownTracker()
|
||||||
fc := NewFallbackChain(ct)
|
fc := NewFallbackChain(ct, nil)
|
||||||
|
|
||||||
candidates := []FallbackCandidate{
|
candidates := []FallbackCandidate{
|
||||||
makeCandidate("openai", "gpt-4"),
|
makeCandidate("openai", "gpt-4"),
|
||||||
|
|
@ -270,7 +270,7 @@ func TestFallback_UnclassifiedError(t *testing.T) {
|
||||||
|
|
||||||
func TestFallback_SuccessResetsCooldown(t *testing.T) {
|
func TestFallback_SuccessResetsCooldown(t *testing.T) {
|
||||||
ct := NewCooldownTracker()
|
ct := NewCooldownTracker()
|
||||||
fc := NewFallbackChain(ct)
|
fc := NewFallbackChain(ct, nil)
|
||||||
|
|
||||||
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
|
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
|
||||||
modelKey := ModelKey("openai", "gpt-4")
|
modelKey := ModelKey("openai", "gpt-4")
|
||||||
|
|
@ -293,11 +293,78 @@ func TestFallback_SuccessResetsCooldown(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertLocalRateLimitSkipsToHealthyFallback(
|
||||||
|
t *testing.T,
|
||||||
|
primaryKey string,
|
||||||
|
fallbackKey string,
|
||||||
|
fallbackProvider string,
|
||||||
|
fallbackModel string,
|
||||||
|
execute func(context.Context, *FallbackChain, []FallbackCandidate,
|
||||||
|
func(context.Context, string, string) (*LLMResponse, error),
|
||||||
|
) (*FallbackResult, error),
|
||||||
|
responseContent string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
rl := NewRateLimiterRegistry()
|
||||||
|
rl.Register(primaryKey, 1)
|
||||||
|
if err := rl.Wait(context.Background(), primaryKey); err != nil {
|
||||||
|
t.Fatalf("failed to pre-drain primary limiter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fc := NewFallbackChain(ct, rl)
|
||||||
|
candidates := []FallbackCandidate{
|
||||||
|
{Provider: "openai", Model: "gpt-4o", IdentityKey: primaryKey},
|
||||||
|
{Provider: fallbackProvider, Model: fallbackModel, IdentityKey: fallbackKey},
|
||||||
|
}
|
||||||
|
|
||||||
|
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||||
|
if provider != fallbackProvider || model != fallbackModel {
|
||||||
|
t.Fatalf("expected fallback candidate to run, got %s/%s", provider, model)
|
||||||
|
}
|
||||||
|
return &LLMResponse{Content: responseContent, FinishReason: "stop"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := execute(ctx, fc, candidates, run)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected fallback success, got error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Provider != fallbackProvider || result.Model != fallbackModel {
|
||||||
|
t.Fatalf("result = %s/%s, want %s/%s", result.Provider, result.Model, fallbackProvider, fallbackModel)
|
||||||
|
}
|
||||||
|
if len(result.Attempts) != 1 || !result.Attempts[0].Skipped {
|
||||||
|
t.Fatalf("expected one skipped primary attempt, got %+v", result.Attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallback_LocalRateLimitSkipsToHealthyFallback(t *testing.T) {
|
||||||
|
assertLocalRateLimitSkipsToHealthyFallback(
|
||||||
|
t,
|
||||||
|
"model_name:primary",
|
||||||
|
"model_name:fallback",
|
||||||
|
"anthropic",
|
||||||
|
"claude",
|
||||||
|
func(
|
||||||
|
ctx context.Context,
|
||||||
|
fc *FallbackChain,
|
||||||
|
candidates []FallbackCandidate,
|
||||||
|
run func(context.Context, string, string) (*LLMResponse, error),
|
||||||
|
) (*FallbackResult, error) {
|
||||||
|
return fc.Execute(ctx, candidates, run)
|
||||||
|
},
|
||||||
|
"fallback ok",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Image Fallback Tests ---
|
// --- Image Fallback Tests ---
|
||||||
|
|
||||||
func TestImageFallback_Success(t *testing.T) {
|
func TestImageFallback_Success(t *testing.T) {
|
||||||
ct := NewCooldownTracker()
|
ct := NewCooldownTracker()
|
||||||
fc := NewFallbackChain(ct)
|
fc := NewFallbackChain(ct, nil)
|
||||||
|
|
||||||
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4o")}
|
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4o")}
|
||||||
result, err := fc.ExecuteImage(context.Background(), candidates, successRun("image result"))
|
result, err := fc.ExecuteImage(context.Background(), candidates, successRun("image result"))
|
||||||
|
|
@ -311,7 +378,7 @@ func TestImageFallback_Success(t *testing.T) {
|
||||||
|
|
||||||
func TestImageFallback_DimensionError(t *testing.T) {
|
func TestImageFallback_DimensionError(t *testing.T) {
|
||||||
ct := NewCooldownTracker()
|
ct := NewCooldownTracker()
|
||||||
fc := NewFallbackChain(ct)
|
fc := NewFallbackChain(ct, nil)
|
||||||
|
|
||||||
candidates := []FallbackCandidate{
|
candidates := []FallbackCandidate{
|
||||||
makeCandidate("openai", "gpt-4o"),
|
makeCandidate("openai", "gpt-4o"),
|
||||||
|
|
@ -335,7 +402,7 @@ func TestImageFallback_DimensionError(t *testing.T) {
|
||||||
|
|
||||||
func TestImageFallback_SizeError(t *testing.T) {
|
func TestImageFallback_SizeError(t *testing.T) {
|
||||||
ct := NewCooldownTracker()
|
ct := NewCooldownTracker()
|
||||||
fc := NewFallbackChain(ct)
|
fc := NewFallbackChain(ct, nil)
|
||||||
|
|
||||||
candidates := []FallbackCandidate{
|
candidates := []FallbackCandidate{
|
||||||
makeCandidate("openai", "gpt-4o"),
|
makeCandidate("openai", "gpt-4o"),
|
||||||
|
|
@ -359,7 +426,7 @@ func TestImageFallback_SizeError(t *testing.T) {
|
||||||
|
|
||||||
func TestImageFallback_RetryOnOtherErrors(t *testing.T) {
|
func TestImageFallback_RetryOnOtherErrors(t *testing.T) {
|
||||||
ct := NewCooldownTracker()
|
ct := NewCooldownTracker()
|
||||||
fc := NewFallbackChain(ct)
|
fc := NewFallbackChain(ct, nil)
|
||||||
|
|
||||||
candidates := []FallbackCandidate{
|
candidates := []FallbackCandidate{
|
||||||
makeCandidate("openai", "gpt-4o"),
|
makeCandidate("openai", "gpt-4o"),
|
||||||
|
|
@ -384,9 +451,28 @@ func TestImageFallback_RetryOnOtherErrors(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImageFallback_LocalRateLimitSkipsToHealthyFallback(t *testing.T) {
|
||||||
|
assertLocalRateLimitSkipsToHealthyFallback(
|
||||||
|
t,
|
||||||
|
"model_name:primary-image",
|
||||||
|
"model_name:fallback-image",
|
||||||
|
"anthropic",
|
||||||
|
"claude-sonnet",
|
||||||
|
func(
|
||||||
|
ctx context.Context,
|
||||||
|
fc *FallbackChain,
|
||||||
|
candidates []FallbackCandidate,
|
||||||
|
run func(context.Context, string, string) (*LLMResponse, error),
|
||||||
|
) (*FallbackResult, error) {
|
||||||
|
return fc.ExecuteImage(ctx, candidates, run)
|
||||||
|
},
|
||||||
|
"image fallback ok",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func TestImageFallback_NoCandidates(t *testing.T) {
|
func TestImageFallback_NoCandidates(t *testing.T) {
|
||||||
ct := NewCooldownTracker()
|
ct := NewCooldownTracker()
|
||||||
fc := NewFallbackChain(ct)
|
fc := NewFallbackChain(ct, nil)
|
||||||
|
|
||||||
_, err := fc.ExecuteImage(context.Background(), nil, successRun("ok"))
|
_, err := fc.ExecuteImage(context.Background(), nil, successRun("ok"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,11 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
|
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
|
||||||
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0, nil)
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, "", 0, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
apiKey, apiBase, proxy, maxTokensField string,
|
apiKey, apiBase, proxy, maxTokensField, userAgent string,
|
||||||
requestTimeoutSeconds int,
|
requestTimeoutSeconds int,
|
||||||
extraBody map[string]any,
|
extraBody map[string]any,
|
||||||
) *HTTPProvider {
|
) *HTTPProvider {
|
||||||
|
|
@ -40,6 +40,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
openai_compat.WithMaxTokensField(maxTokensField),
|
openai_compat.WithMaxTokensField(maxTokensField),
|
||||||
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
||||||
openai_compat.WithExtraBody(extraBody),
|
openai_compat.WithExtraBody(extraBody),
|
||||||
|
openai_compat.WithUserAgent(userAgent),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ type Provider struct {
|
||||||
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
extraBody map[string]any // Additional fields to inject into request body
|
extraBody map[string]any // Additional fields to inject into request body
|
||||||
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(*Provider)
|
type Option func(*Provider)
|
||||||
|
|
@ -44,6 +45,7 @@ const defaultRequestTimeout = common.DefaultRequestTimeout
|
||||||
|
|
||||||
var stripModelPrefixProviders = map[string]struct{}{
|
var stripModelPrefixProviders = map[string]struct{}{
|
||||||
"litellm": {},
|
"litellm": {},
|
||||||
|
"venice": {},
|
||||||
"moonshot": {},
|
"moonshot": {},
|
||||||
"nvidia": {},
|
"nvidia": {},
|
||||||
"groq": {},
|
"groq": {},
|
||||||
|
|
@ -65,6 +67,12 @@ func WithMaxTokensField(maxTokensField string) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithUserAgent(userAgent string) Option {
|
||||||
|
return func(p *Provider) {
|
||||||
|
p.userAgent = userAgent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func WithRequestTimeout(timeout time.Duration) Option {
|
func WithRequestTimeout(timeout time.Duration) Option {
|
||||||
return func(p *Provider) {
|
return func(p *Provider) {
|
||||||
if timeout > 0 {
|
if timeout > 0 {
|
||||||
|
|
@ -197,6 +205,9 @@ func (p *Provider) Chat(
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if p.userAgent != "" {
|
||||||
|
req.Header.Set("User-Agent", p.userAgent)
|
||||||
|
}
|
||||||
if p.apiKey != "" {
|
if p.apiKey != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -479,6 +479,11 @@ func TestProviderChat_StripsKnownProviderPrefixes(t *testing.T) {
|
||||||
input: "lmstudio/openai/gpt-oss-20b",
|
input: "lmstudio/openai/gpt-oss-20b",
|
||||||
wantModel: "openai/gpt-oss-20b",
|
wantModel: "openai/gpt-oss-20b",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "strips venice prefix",
|
||||||
|
input: "venice/venice-uncensored",
|
||||||
|
wantModel: "venice-uncensored",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "strips deepseek prefix",
|
name: "strips deepseek prefix",
|
||||||
input: "deepseek/deepseek-chat",
|
input: "deepseek/deepseek-chat",
|
||||||
|
|
@ -587,6 +592,9 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) {
|
||||||
if got := normalizeModel("lmstudio/openai/gpt-oss-20b", "http://localhost:1234/v1"); got != "openai/gpt-oss-20b" {
|
if got := normalizeModel("lmstudio/openai/gpt-oss-20b", "http://localhost:1234/v1"); got != "openai/gpt-oss-20b" {
|
||||||
t.Fatalf("normalizeModel(lmstudio) = %q, want %q", got, "openai/gpt-oss-20b")
|
t.Fatalf("normalizeModel(lmstudio) = %q, want %q", got, "openai/gpt-oss-20b")
|
||||||
}
|
}
|
||||||
|
if got := normalizeModel("venice/venice-uncensored", "https://api.venice.ai/api/v1"); got != "venice-uncensored" {
|
||||||
|
t.Fatalf("normalizeModel(venice) = %q, want %q", got, "venice-uncensored")
|
||||||
|
}
|
||||||
if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" {
|
if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" {
|
||||||
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
|
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
144
pkg/providers/ratelimiter.go
Normal file
144
pkg/providers/ratelimiter.go
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RateLimiter implements a token-bucket rate limiter for a single key.
|
||||||
|
// Allows up to RPM requests per minute with a burst equal to RPM.
|
||||||
|
// Thread-safe.
|
||||||
|
type RateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
rpm int
|
||||||
|
tokens float64
|
||||||
|
maxBurst float64
|
||||||
|
lastTick time.Time
|
||||||
|
nowFunc func() time.Time // for testing
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *RateLimiter) refillLocked(now time.Time) {
|
||||||
|
elapsed := now.Sub(rl.lastTick).Seconds()
|
||||||
|
rl.lastTick = now
|
||||||
|
|
||||||
|
// Refill tokens proportional to elapsed time.
|
||||||
|
refill := elapsed * float64(rl.rpm) / 60.0
|
||||||
|
rl.tokens = min(rl.maxBurst, rl.tokens+refill)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRateLimiter creates a RateLimiter that allows rpm requests/minute.
|
||||||
|
func newRateLimiter(rpm int) *RateLimiter {
|
||||||
|
return &RateLimiter{
|
||||||
|
rpm: rpm,
|
||||||
|
tokens: float64(rpm), // start full
|
||||||
|
maxBurst: float64(rpm),
|
||||||
|
lastTick: time.Now(),
|
||||||
|
nowFunc: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait blocks until a token is available or ctx is canceled.
|
||||||
|
// Returns ctx.Err() if canceled while waiting.
|
||||||
|
func (rl *RateLimiter) Wait(ctx context.Context) error {
|
||||||
|
for {
|
||||||
|
rl.mu.Lock()
|
||||||
|
now := rl.nowFunc()
|
||||||
|
rl.refillLocked(now)
|
||||||
|
|
||||||
|
if rl.tokens >= 1.0 {
|
||||||
|
rl.tokens--
|
||||||
|
rl.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate how long until a token is available.
|
||||||
|
deficit := 1.0 - rl.tokens
|
||||||
|
waitSec := deficit / (float64(rl.rpm) / 60.0)
|
||||||
|
rl.mu.Unlock()
|
||||||
|
|
||||||
|
timer := time.NewTimer(time.Duration(waitSec * float64(time.Second)))
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if !timer.Stop() {
|
||||||
|
<-timer.C
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
// Loop to re-check (another goroutine may have consumed the token).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TryAcquire attempts to consume a token without blocking.
|
||||||
|
func (rl *RateLimiter) TryAcquire() bool {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
rl.refillLocked(rl.nowFunc())
|
||||||
|
if rl.tokens < 1.0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rl.tokens--
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimiterRegistry holds per-candidate rate limiters.
|
||||||
|
// Candidates with RPM=0 are unrestricted.
|
||||||
|
// Thread-safe for concurrent reads/writes.
|
||||||
|
type RateLimiterRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
limiters map[string]*RateLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRateLimiterRegistry creates an empty registry.
|
||||||
|
func NewRateLimiterRegistry() *RateLimiterRegistry {
|
||||||
|
return &RateLimiterRegistry{
|
||||||
|
limiters: make(map[string]*RateLimiter),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register adds a rate limiter for the given key at the given RPM.
|
||||||
|
// If rpm <= 0, no limiter is registered (unrestricted).
|
||||||
|
func (r *RateLimiterRegistry) Register(key string, rpm int) {
|
||||||
|
if rpm <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.limiters[key] = newRateLimiter(rpm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait acquires a token for the given key, blocking if needed.
|
||||||
|
// If no limiter is registered for key, returns immediately.
|
||||||
|
func (r *RateLimiterRegistry) Wait(ctx context.Context, key string) error {
|
||||||
|
r.mu.RLock()
|
||||||
|
rl := r.limiters[key]
|
||||||
|
r.mu.RUnlock()
|
||||||
|
if rl == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return rl.Wait(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TryAcquire attempts to consume a token for the given key without blocking.
|
||||||
|
// If no limiter is registered for key, it returns true.
|
||||||
|
func (r *RateLimiterRegistry) TryAcquire(key string) bool {
|
||||||
|
r.mu.RLock()
|
||||||
|
rl := r.limiters[key]
|
||||||
|
r.mu.RUnlock()
|
||||||
|
if rl == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return rl.TryAcquire()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterCandidates registers rate limiters for all candidates that have RPM > 0.
|
||||||
|
// Candidates with RPM == 0 are ignored (no restriction).
|
||||||
|
func (r *RateLimiterRegistry) RegisterCandidates(candidates []FallbackCandidate) {
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c.RPM > 0 {
|
||||||
|
r.Register(c.StableKey(), c.RPM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
209
pkg/providers/ratelimiter_test.go
Normal file
209
pkg/providers/ratelimiter_test.go
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRateLimiter_AllowsUpToRPM verifies that up to RPM requests pass immediately
|
||||||
|
// (burst capacity) and the (RPM+1)-th request is delayed.
|
||||||
|
func TestRateLimiter_AllowsUpToRPM(t *testing.T) {
|
||||||
|
rpm := 5
|
||||||
|
rl := newRateLimiter(rpm)
|
||||||
|
|
||||||
|
// All rpm tokens should be available immediately (bucket starts full).
|
||||||
|
for i := 0; i < rpm; i++ {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
if err := rl.Wait(ctx); err != nil {
|
||||||
|
t.Fatalf("request %d should pass immediately, got: %v", i+1, err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next request must wait; cancel it to confirm it blocks.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
err := rl.Wait(ctx)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected request beyond RPM to block, but it passed immediately")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimiter_ContextCancellation verifies that a blocked Wait respects cancellation.
|
||||||
|
func TestRateLimiter_ContextCancellation(t *testing.T) {
|
||||||
|
rl := newRateLimiter(1)
|
||||||
|
|
||||||
|
// Drain the one token.
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := rl.Wait(ctx); err != nil {
|
||||||
|
t.Fatalf("first request failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second request should block; cancel it.
|
||||||
|
cancelCtx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
err := rl.Wait(cancelCtx)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected cancellation error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimiter_TokenRefill verifies that tokens refill over time.
|
||||||
|
func TestRateLimiter_TokenRefill(t *testing.T) {
|
||||||
|
rpm := 60 // 1 token per second
|
||||||
|
rl := newRateLimiter(rpm)
|
||||||
|
|
||||||
|
// Drain all tokens.
|
||||||
|
for i := 0; i < rpm; i++ {
|
||||||
|
rl.Wait(context.Background()) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance time via nowFunc: simulate 2 seconds passing (should give 2 tokens).
|
||||||
|
start := time.Now()
|
||||||
|
rl.nowFunc = func() time.Time { return start.Add(2 * time.Second) }
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if err := rl.Wait(ctx); err != nil {
|
||||||
|
t.Fatalf("expected refilled token to be available: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimiterRegistry_NoLimiter verifies that keys without a registered limiter pass freely.
|
||||||
|
func TestRateLimiterRegistry_NoLimiter(t *testing.T) {
|
||||||
|
r := NewRateLimiterRegistry()
|
||||||
|
ctx := context.Background()
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
if err := r.Wait(ctx, "unregistered/key"); err != nil {
|
||||||
|
t.Fatalf("unregistered key should not block: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimiterRegistry_ZeroRPM verifies that RPM=0 means no limiter is registered.
|
||||||
|
func TestRateLimiterRegistry_ZeroRPM(t *testing.T) {
|
||||||
|
r := NewRateLimiterRegistry()
|
||||||
|
r.Register("some/key", 0)
|
||||||
|
ctx := context.Background()
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
if err := r.Wait(ctx, "some/key"); err != nil {
|
||||||
|
t.Fatalf("zero-RPM key should not block: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimiterRegistry_Enforcement verifies the registry enforces RPM per key.
|
||||||
|
func TestRateLimiterRegistry_Enforcement(t *testing.T) {
|
||||||
|
r := NewRateLimiterRegistry()
|
||||||
|
r.Register("openai/gpt-4o", 3)
|
||||||
|
|
||||||
|
// First 3 calls should pass (burst = RPM).
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
if err := r.Wait(ctx, "openai/gpt-4o"); err != nil {
|
||||||
|
t.Fatalf("call %d should pass: %v", i+1, err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4th call should block.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if err := r.Wait(ctx, "openai/gpt-4o"); err == nil {
|
||||||
|
t.Fatal("4th call should have been rate-limited")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimiterRegistry_RegisterCandidates verifies that RegisterCandidates
|
||||||
|
// correctly picks up RPM from FallbackCandidate.
|
||||||
|
func TestRateLimiterRegistry_RegisterCandidates(t *testing.T) {
|
||||||
|
r := NewRateLimiterRegistry()
|
||||||
|
candidates := []FallbackCandidate{
|
||||||
|
{Provider: "openai", Model: "gpt-4o", RPM: 2},
|
||||||
|
{Provider: "anthropic", Model: "claude-3", RPM: 0}, // no limit
|
||||||
|
}
|
||||||
|
r.RegisterCandidates(candidates)
|
||||||
|
|
||||||
|
// openai/gpt-4o: 2 tokens burst, 3rd should block.
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
if err := r.Wait(ctx, "openai/gpt-4o"); err != nil {
|
||||||
|
t.Fatalf("openai call %d should pass: %v", i+1, err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if err := r.Wait(ctx, "openai/gpt-4o"); err == nil {
|
||||||
|
t.Fatal("openai 3rd call should have been limited")
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthropic/claude-3: no limit, should always pass.
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
if err := r.Wait(context.Background(), "anthropic/claude-3"); err != nil {
|
||||||
|
t.Fatalf("anthropic call should not be limited: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiterRegistry_RegisterCandidatesUsesStableIdentity(t *testing.T) {
|
||||||
|
r := NewRateLimiterRegistry()
|
||||||
|
candidates := []FallbackCandidate{
|
||||||
|
{Provider: "openai", Model: "gpt-4o", RPM: 1, IdentityKey: "model_name:primary"},
|
||||||
|
{Provider: "openai", Model: "gpt-4o", RPM: 2, IdentityKey: "model_name:fallback"},
|
||||||
|
}
|
||||||
|
r.RegisterCandidates(candidates)
|
||||||
|
|
||||||
|
if err := r.Wait(context.Background(), "model_name:primary"); err != nil {
|
||||||
|
t.Fatalf("primary first call should pass: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.Wait(context.Background(), "model_name:fallback"); err != nil {
|
||||||
|
t.Fatalf("fallback first call should pass: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.Wait(context.Background(), "model_name:fallback"); err != nil {
|
||||||
|
t.Fatalf("fallback second call should pass: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxPrimary, cancelPrimary := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancelPrimary()
|
||||||
|
if err := r.Wait(ctxPrimary, "model_name:primary"); err == nil {
|
||||||
|
t.Fatal("primary second call should have been limited")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxFallback, cancelFallback := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancelFallback()
|
||||||
|
if err := r.Wait(ctxFallback, "model_name:fallback"); err == nil {
|
||||||
|
t.Fatal("fallback third call should have been limited")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimiter_Concurrency verifies thread safety under concurrent access.
|
||||||
|
func TestRateLimiter_Concurrency(t *testing.T) {
|
||||||
|
rpm := 20
|
||||||
|
rl := newRateLimiter(rpm)
|
||||||
|
var passed atomic.Int64
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Launch 30 goroutines; only ~20 should pass immediately.
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if rl.Wait(ctx) == nil {
|
||||||
|
passed.Add(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
got := passed.Load()
|
||||||
|
// Allow small timing slack: between rpm-2 and rpm+2.
|
||||||
|
if got < int64(rpm-2) || got > int64(rpm+2) {
|
||||||
|
t.Fatalf("expected ~%d immediate passes, got %d", rpm, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
package tools
|
package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"math"
|
"math"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
|
@ -20,7 +24,11 @@ import (
|
||||||
|
|
||||||
const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow
|
const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow
|
||||||
|
|
||||||
func validatePathWithAllowPaths(path, workspace string, restrict bool, patterns []*regexp.Regexp) (string, error) {
|
func validatePathWithAllowPaths(
|
||||||
|
path, workspace string,
|
||||||
|
restrict bool,
|
||||||
|
patterns []*regexp.Regexp,
|
||||||
|
) (string, error) {
|
||||||
if workspace == "" {
|
if workspace == "" {
|
||||||
return path, fmt.Errorf("workspace is not defined")
|
return path, fmt.Errorf("workspace is not defined")
|
||||||
}
|
}
|
||||||
|
|
@ -253,6 +261,11 @@ type ReadFileTool struct {
|
||||||
maxSize int64
|
maxSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReadFileLinesTool struct {
|
||||||
|
fs fileSystem
|
||||||
|
maxSize int64
|
||||||
|
}
|
||||||
|
|
||||||
func NewReadFileTool(
|
func NewReadFileTool(
|
||||||
workspace string,
|
workspace string,
|
||||||
restrict bool,
|
restrict bool,
|
||||||
|
|
@ -275,14 +288,53 @@ func NewReadFileTool(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewReadFileBytesTool(
|
||||||
|
workspace string,
|
||||||
|
restrict bool,
|
||||||
|
maxReadFileSize int,
|
||||||
|
allowPaths ...[]*regexp.Regexp,
|
||||||
|
) *ReadFileTool {
|
||||||
|
return NewReadFileTool(workspace, restrict, maxReadFileSize, allowPaths...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReadFileLinesTool(
|
||||||
|
workspace string,
|
||||||
|
restrict bool,
|
||||||
|
maxReadFileSize int,
|
||||||
|
allowPaths ...[]*regexp.Regexp,
|
||||||
|
) *ReadFileLinesTool {
|
||||||
|
var patterns []*regexp.Regexp
|
||||||
|
if len(allowPaths) > 0 {
|
||||||
|
patterns = allowPaths[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSize := int64(maxReadFileSize)
|
||||||
|
if maxSize <= 0 {
|
||||||
|
maxSize = MaxReadFileSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ReadFileLinesTool{
|
||||||
|
fs: buildFs(workspace, restrict, patterns),
|
||||||
|
maxSize: maxSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (t *ReadFileTool) Name() string {
|
func (t *ReadFileTool) Name() string {
|
||||||
return "read_file"
|
return "read_file"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *ReadFileLinesTool) Name() string {
|
||||||
|
return "read_file"
|
||||||
|
}
|
||||||
|
|
||||||
func (t *ReadFileTool) Description() string {
|
func (t *ReadFileTool) Description() string {
|
||||||
return "Read the contents of a file. Supports pagination via `offset` and `length`."
|
return "Read the contents of a file. Supports pagination via `offset` and `length`."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *ReadFileLinesTool) Description() string {
|
||||||
|
return "Read a UTF-8 text file from the filesystem. Output always includes line numbers in the format `LINE_NUMBER|LINE_CONTENT` (1-indexed). Supports partial reads via `start_line` and `max_lines` for large text files."
|
||||||
|
}
|
||||||
|
|
||||||
func (t *ReadFileTool) Parameters() map[string]any {
|
func (t *ReadFileTool) Parameters() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -306,6 +358,28 @@ func (t *ReadFileTool) Parameters() map[string]any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *ReadFileLinesTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"path": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to the file to read.",
|
||||||
|
},
|
||||||
|
"start_line": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Line number to start reading from (1-indexed, inclusive).",
|
||||||
|
"default": 1,
|
||||||
|
},
|
||||||
|
"max_lines": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of lines to read.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"path"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
path, ok := args["path"].(string)
|
path, ok := args["path"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -447,6 +521,302 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
return NewToolResult(header + "\n\n" + string(data))
|
return NewToolResult(header + "\n\n" + string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *ReadFileLinesTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
path, ok := args["path"].(string)
|
||||||
|
if !ok {
|
||||||
|
return ErrorResult("path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
startLine, err := getInt64Arg(args, "start_line", 1)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
if startLine < 1 {
|
||||||
|
return ErrorResult("start_line must be >= 1")
|
||||||
|
}
|
||||||
|
if _, exists := args["offset"]; exists {
|
||||||
|
return ErrorResult("offset is not supported in line mode; use start_line")
|
||||||
|
}
|
||||||
|
if _, exists := args["length"]; exists {
|
||||||
|
return ErrorResult("length is not supported in line mode; use max_lines")
|
||||||
|
}
|
||||||
|
if _, exists := args["limit"]; exists {
|
||||||
|
return ErrorResult("limit is not supported in line mode; use max_lines")
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := int64(-1)
|
||||||
|
if raw, exists := args["max_lines"]; exists && raw != nil {
|
||||||
|
limit, err = getInt64Arg(args, "max_lines", -1)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
return ErrorResult("max_lines, if provided, must be > 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := t.fs.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if info, statErr := file.Stat(); statErr == nil && info.IsDir() {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to open file: path is a directory: %s", path))
|
||||||
|
}
|
||||||
|
|
||||||
|
sample := make([]byte, 512)
|
||||||
|
sampleN, readErr := file.Read(sample)
|
||||||
|
if readErr != nil && readErr != io.EOF {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to read file: %v", readErr))
|
||||||
|
}
|
||||||
|
sample = sample[:sampleN]
|
||||||
|
if isBinaryReadFileData(sample) {
|
||||||
|
return ErrorResult("file appears to be binary; switch read_file mode to 'bytes' for byte-based inspection")
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReaderSize(io.MultiReader(bytes.NewReader(sample), file), 32*1024)
|
||||||
|
|
||||||
|
var content strings.Builder
|
||||||
|
lineIndex := int64(1)
|
||||||
|
var linesRead int64
|
||||||
|
var fileBytesRead int64
|
||||||
|
var outputBytesRead int64
|
||||||
|
var reachedEOF bool
|
||||||
|
var byteBudgetTruncated bool
|
||||||
|
var lineTruncated bool
|
||||||
|
|
||||||
|
for lineIndex < startLine {
|
||||||
|
hasLine, consumeErr := consumeNextLine(reader)
|
||||||
|
if consumeErr != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to read file content: %v", consumeErr))
|
||||||
|
}
|
||||||
|
if !hasLine {
|
||||||
|
reachedEOF = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lineIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
for !reachedEOF && (limit < 0 || linesRead < limit) {
|
||||||
|
prefix := formatReadFileLinePrefix(lineIndex)
|
||||||
|
remaining := t.maxSize - outputBytesRead - int64(len(prefix))
|
||||||
|
if remaining <= 0 {
|
||||||
|
byteBudgetTruncated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
line, complete, hasLine, readLineErr := readNextLinePrefix(reader, remaining)
|
||||||
|
if readLineErr != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to read file content: %v", readLineErr))
|
||||||
|
}
|
||||||
|
if !hasLine {
|
||||||
|
reachedEOF = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
content.WriteString(prefix)
|
||||||
|
content.Write(line)
|
||||||
|
fileBytesRead += int64(len(line))
|
||||||
|
outputBytesRead += int64(len(prefix) + len(line))
|
||||||
|
linesRead++
|
||||||
|
lineIndex++
|
||||||
|
|
||||||
|
if !complete {
|
||||||
|
byteBudgetTruncated = true
|
||||||
|
lineTruncated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reachedEOF && !lineTruncated {
|
||||||
|
hasMoreContent, peekErr := readerHasMoreContent(reader)
|
||||||
|
if peekErr != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to inspect remaining file content: %v", peekErr))
|
||||||
|
}
|
||||||
|
if !hasMoreContent {
|
||||||
|
reachedEOF = true
|
||||||
|
byteBudgetTruncated = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if linesRead == 0 && content.Len() == 0 {
|
||||||
|
return NewToolResult(fmt.Sprintf("[END OF FILE - no content at or after start_line=%d]", startLine))
|
||||||
|
}
|
||||||
|
|
||||||
|
start := startLine
|
||||||
|
endLine := startLine + linesRead - 1
|
||||||
|
displayPath := filepath.Base(path)
|
||||||
|
header := fmt.Sprintf(
|
||||||
|
"[file: %s | read: lines %d-%d (1-indexed) | file_bytes: %d | output_bytes: %d]",
|
||||||
|
displayPath, start, endLine, fileBytesRead, outputBytesRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case lineTruncated:
|
||||||
|
header += fmt.Sprintf(
|
||||||
|
"\n[TRUNCATED - line %d exceeded the %d byte read budget and was cut mid-line.]",
|
||||||
|
endLine,
|
||||||
|
t.maxSize,
|
||||||
|
)
|
||||||
|
case byteBudgetTruncated:
|
||||||
|
if limit > 0 {
|
||||||
|
header += fmt.Sprintf(
|
||||||
|
"\n[TRUNCATED - byte budget reached. Call read_file again with start_line=%d and max_lines=%d to continue at the next line.]",
|
||||||
|
startLine+linesRead,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
header += fmt.Sprintf(
|
||||||
|
"\n[TRUNCATED - byte budget reached. Call read_file again with start_line=%d to continue at the next line.]",
|
||||||
|
startLine+linesRead,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case !reachedEOF && limit > 0 && linesRead >= limit:
|
||||||
|
header += fmt.Sprintf(
|
||||||
|
"\n[PARTIAL - more content remains. Call read_file again with start_line=%d and max_lines=%d to continue.]",
|
||||||
|
startLine+linesRead,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
header += "\n[END OF FILE - no further content.]"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("tool", "ReadFileTool execution completed successfully",
|
||||||
|
map[string]any{
|
||||||
|
"path": path,
|
||||||
|
"lines_read": linesRead,
|
||||||
|
"file_bytes_read": fileBytesRead,
|
||||||
|
"output_bytes_read": outputBytesRead,
|
||||||
|
"truncated": byteBudgetTruncated,
|
||||||
|
"tool": t.Name(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return NewToolResult(header + "\n\n" + content.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatReadFileLinePrefix(lineNumber int64) string {
|
||||||
|
return strconv.FormatInt(lineNumber, 10) + "|"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBinaryReadFileData(data []byte) bool {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
sample := data
|
||||||
|
if len(sample) > 512 {
|
||||||
|
sample = sample[:512]
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.IndexByte(sample, 0) >= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := http.DetectContentType(sample)
|
||||||
|
if strings.HasPrefix(contentType, "text/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(contentType, "/json") ||
|
||||||
|
strings.HasSuffix(contentType, "+json") ||
|
||||||
|
strings.HasSuffix(contentType, "/xml") ||
|
||||||
|
strings.HasSuffix(contentType, "+xml") ||
|
||||||
|
strings.Contains(contentType, "javascript") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !utf8.Valid(sample) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
controlChars := 0
|
||||||
|
for _, b := range sample {
|
||||||
|
if b < 0x20 && b != '\n' && b != '\r' && b != '\t' && b != '\f' && b != '\b' {
|
||||||
|
controlChars++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return float64(controlChars)/float64(len(sample)) > 0.1
|
||||||
|
}
|
||||||
|
|
||||||
|
func consumeNextLine(reader *bufio.Reader) (bool, error) {
|
||||||
|
sawData := false
|
||||||
|
|
||||||
|
for {
|
||||||
|
fragment, err := reader.ReadSlice('\n')
|
||||||
|
if len(fragment) > 0 {
|
||||||
|
sawData = true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return true, nil
|
||||||
|
case errors.Is(err, bufio.ErrBufferFull):
|
||||||
|
continue
|
||||||
|
case errors.Is(err, io.EOF):
|
||||||
|
return sawData, nil
|
||||||
|
default:
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readNextLinePrefix(reader *bufio.Reader, maxBytes int64) ([]byte, bool, bool, error) {
|
||||||
|
if maxBytes <= 0 {
|
||||||
|
return nil, false, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
sawData := false
|
||||||
|
complete := true
|
||||||
|
|
||||||
|
for {
|
||||||
|
fragment, err := reader.ReadSlice('\n')
|
||||||
|
if len(fragment) > 0 {
|
||||||
|
sawData = true
|
||||||
|
if remaining := maxBytes - int64(out.Len()); remaining > 0 {
|
||||||
|
take := len(fragment)
|
||||||
|
if int64(take) > remaining {
|
||||||
|
take = int(remaining)
|
||||||
|
complete = false
|
||||||
|
}
|
||||||
|
out.Write(fragment[:take])
|
||||||
|
} else {
|
||||||
|
complete = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return out.Bytes(), complete, sawData, nil
|
||||||
|
case errors.Is(err, bufio.ErrBufferFull):
|
||||||
|
if !complete {
|
||||||
|
return out.Bytes(), false, true, nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case errors.Is(err, io.EOF):
|
||||||
|
if !sawData {
|
||||||
|
return nil, true, false, nil
|
||||||
|
}
|
||||||
|
return out.Bytes(), complete, true, nil
|
||||||
|
default:
|
||||||
|
return nil, false, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readerHasMoreContent(reader *bufio.Reader) (bool, error) {
|
||||||
|
_, err := reader.Peek(1)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return true, nil
|
||||||
|
case errors.Is(err, io.EOF):
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// getInt64Arg extracts an integer argument from the args map, returning the
|
// getInt64Arg extracts an integer argument from the args map, returning the
|
||||||
// provided default if the key is absent.
|
// provided default if the key is absent.
|
||||||
func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) {
|
func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) {
|
||||||
|
|
@ -483,7 +853,11 @@ type WriteFileTool struct {
|
||||||
fs fileSystem
|
fs fileSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool {
|
func NewWriteFileTool(
|
||||||
|
workspace string,
|
||||||
|
restrict bool,
|
||||||
|
allowPaths ...[]*regexp.Regexp,
|
||||||
|
) *WriteFileTool {
|
||||||
var patterns []*regexp.Regexp
|
var patterns []*regexp.Regexp
|
||||||
if len(allowPaths) > 0 {
|
if len(allowPaths) > 0 {
|
||||||
patterns = allowPaths[0]
|
patterns = allowPaths[0]
|
||||||
|
|
@ -536,7 +910,9 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
|
||||||
|
|
||||||
if !overwrite {
|
if !overwrite {
|
||||||
if _, err := t.fs.Open(path); err == nil {
|
if _, err := t.fs.Open(path); err == nil {
|
||||||
return ErrorResult(fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path))
|
return ErrorResult(
|
||||||
|
fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
|
||||||
testFile := filepath.Join(tmpDir, "test.txt")
|
testFile := filepath.Join(tmpDir, "test.txt")
|
||||||
os.WriteFile(testFile, []byte("test content"), 0o644)
|
os.WriteFile(testFile, []byte("test content"), 0o644)
|
||||||
|
|
||||||
tool := NewReadFileTool("", false, MaxReadFileSize)
|
tool := NewReadFileBytesTool("", false, MaxReadFileSize)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
"path": testFile,
|
"path": testFile,
|
||||||
|
|
@ -45,7 +45,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
|
||||||
|
|
||||||
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
|
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
|
||||||
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
|
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
|
||||||
tool := NewReadFileTool("", false, MaxReadFileSize)
|
tool := NewReadFileBytesTool("", false, MaxReadFileSize)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
"path": "/nonexistent_file_12345.txt",
|
"path": "/nonexistent_file_12345.txt",
|
||||||
|
|
@ -59,8 +59,13 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should contain error message
|
// Should contain error message
|
||||||
if !strings.Contains(result.ForLLM, "failed to open file") && !strings.Contains(result.ForUser, "failed to read") {
|
if !strings.Contains(result.ForLLM, "failed to open file") &&
|
||||||
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
!strings.Contains(result.ForUser, "failed to open") {
|
||||||
|
t.Errorf(
|
||||||
|
"Expected error message, got ForLLM: %s, ForUser: %s",
|
||||||
|
result.ForLLM,
|
||||||
|
result.ForUser,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,7 +83,8 @@ func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should mention required parameter
|
// Should mention required parameter
|
||||||
if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") {
|
if !strings.Contains(result.ForLLM, "path is required") &&
|
||||||
|
!strings.Contains(result.ForUser, "path is required") {
|
||||||
t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM)
|
t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -297,7 +303,12 @@ func TestFilesystemTool_WriteFile_OverwriteSandboxed(t *testing.T) {
|
||||||
"content": "replaced in sandbox",
|
"content": "replaced in sandbox",
|
||||||
"overwrite": true,
|
"overwrite": true,
|
||||||
})
|
})
|
||||||
assert.False(t, result.IsError, "expected success in sandbox mode with overwrite=true, got: %s", result.ForLLM)
|
assert.False(
|
||||||
|
t,
|
||||||
|
result.IsError,
|
||||||
|
"expected success in sandbox mode with overwrite=true, got: %s",
|
||||||
|
result.ForLLM,
|
||||||
|
)
|
||||||
|
|
||||||
data, err := os.ReadFile(filepath.Join(workspace, testFile))
|
data, err := os.ReadFile(filepath.Join(workspace, testFile))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
@ -325,7 +336,8 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should list files and directories
|
// Should list files and directories
|
||||||
if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") {
|
if !strings.Contains(result.ForLLM, "file1.txt") ||
|
||||||
|
!strings.Contains(result.ForLLM, "file2.txt") {
|
||||||
t.Errorf("Expected files in listing, got: %s", result.ForLLM)
|
t.Errorf("Expected files in listing, got: %s", result.ForLLM)
|
||||||
}
|
}
|
||||||
if !strings.Contains(result.ForLLM, "subdir") {
|
if !strings.Contains(result.ForLLM, "subdir") {
|
||||||
|
|
@ -349,8 +361,13 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should contain error message
|
// Should contain error message
|
||||||
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
|
if !strings.Contains(result.ForLLM, "failed to read") &&
|
||||||
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
!strings.Contains(result.ForUser, "failed to read") {
|
||||||
|
t.Errorf(
|
||||||
|
"Expected error message, got ForLLM: %s, ForUser: %s",
|
||||||
|
result.ForLLM,
|
||||||
|
result.ForUser,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -397,7 +414,8 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
|
||||||
// os.Root might return different errors depending on platform/implementation
|
// os.Root might return different errors depending on platform/implementation
|
||||||
// but it definitely should error.
|
// but it definitely should error.
|
||||||
// Our wrapper returns "access denied or file not found"
|
// Our wrapper returns "access denied or file not found"
|
||||||
if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") &&
|
if !strings.Contains(result.ForLLM, "access denied") &&
|
||||||
|
!strings.Contains(result.ForLLM, "file not found") &&
|
||||||
!strings.Contains(result.ForLLM, "no such file") {
|
!strings.Contains(result.ForLLM, "no such file") {
|
||||||
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
|
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
@ -416,10 +434,20 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
// We EXPECT IsError=true (access blocked due to empty workspace)
|
// We EXPECT IsError=true (access blocked due to empty workspace)
|
||||||
assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM)
|
assert.True(
|
||||||
|
t,
|
||||||
|
result.IsError,
|
||||||
|
"Security Regression: Empty workspace allowed access! content: %s",
|
||||||
|
result.ForLLM,
|
||||||
|
)
|
||||||
|
|
||||||
// Verify it failed for the right reason
|
// Verify it failed for the right reason
|
||||||
assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error")
|
assert.Contains(
|
||||||
|
t,
|
||||||
|
result.ForLLM,
|
||||||
|
"workspace is not defined",
|
||||||
|
"Expected 'workspace is not defined' error",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases:
|
// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases:
|
||||||
|
|
@ -653,7 +681,10 @@ func TestWhitelistFs_BlocksSymlinkEscapeInAllowedDir(t *testing.T) {
|
||||||
patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))}
|
patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))}
|
||||||
tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns)
|
tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns)
|
||||||
|
|
||||||
result := tool.Execute(context.Background(), map[string]any{"path": filepath.Join(linkPath, "secret.txt")})
|
result := tool.Execute(
|
||||||
|
context.Background(),
|
||||||
|
map[string]any{"path": filepath.Join(linkPath, "secret.txt")},
|
||||||
|
)
|
||||||
if !result.IsError {
|
if !result.IsError {
|
||||||
t.Fatalf("expected symlink escape from allowed dir to be blocked, got: %s", result.ForLLM)
|
t.Fatalf("expected symlink escape from allowed dir to be blocked, got: %s", result.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
@ -726,7 +757,6 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
testFile := filepath.Join(tmpDir, "pagination_test.txt")
|
testFile := filepath.Join(tmpDir, "pagination_test.txt")
|
||||||
|
|
||||||
// Create a test file with exactly 26 bytes of content
|
|
||||||
fullContent := "abcdefghijklmnopqrstuvwxyz"
|
fullContent := "abcdefghijklmnopqrstuvwxyz"
|
||||||
err := os.WriteFile(testFile, []byte(fullContent), 0o644)
|
err := os.WriteFile(testFile, []byte(fullContent), 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -748,15 +778,12 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
|
||||||
t.Fatalf("Chunk 1 failed: %s", result1.ForLLM)
|
t.Fatalf("Chunk 1 failed: %s", result1.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expect the first 10 characters
|
|
||||||
if !strings.Contains(result1.ForLLM, "abcdefghij") {
|
if !strings.Contains(result1.ForLLM, "abcdefghij") {
|
||||||
t.Errorf("Chunk 1 should contain 'abcdefghij', got: %s", result1.ForLLM)
|
t.Errorf("Chunk 1 should contain 'abcdefghij', got: %s", result1.ForLLM)
|
||||||
}
|
}
|
||||||
// Expect the header to indicate the file is truncated
|
|
||||||
if !strings.Contains(result1.ForLLM, "[TRUNCATED") {
|
if !strings.Contains(result1.ForLLM, "[TRUNCATED") {
|
||||||
t.Errorf("Chunk 1 header should indicate truncation, got: %s", result1.ForLLM)
|
t.Errorf("Chunk 1 header should indicate truncation, got: %s", result1.ForLLM)
|
||||||
}
|
}
|
||||||
// Expect the header to suggest the next offset (10)
|
|
||||||
if !strings.Contains(result1.ForLLM, "offset=10") {
|
if !strings.Contains(result1.ForLLM, "offset=10") {
|
||||||
t.Errorf("Chunk 1 header should suggest next offset=10, got: %s", result1.ForLLM)
|
t.Errorf("Chunk 1 header should suggest next offset=10, got: %s", result1.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
@ -773,17 +800,14 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
|
||||||
t.Fatalf("Chunk 2 failed: %s", result2.ForLLM)
|
t.Fatalf("Chunk 2 failed: %s", result2.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expect the next 10 characters
|
|
||||||
if !strings.Contains(result2.ForLLM, "klmnopqrst") {
|
if !strings.Contains(result2.ForLLM, "klmnopqrst") {
|
||||||
t.Errorf("Chunk 2 should contain 'klmnopqrst', got: %s", result2.ForLLM)
|
t.Errorf("Chunk 2 should contain 'klmnopqrst', got: %s", result2.ForLLM)
|
||||||
}
|
}
|
||||||
// Expect the header to suggest the next offset (20)
|
|
||||||
if !strings.Contains(result2.ForLLM, "offset=20") {
|
if !strings.Contains(result2.ForLLM, "offset=20") {
|
||||||
t.Errorf("Chunk 2 header should suggest next offset=20, got: %s", result2.ForLLM)
|
t.Errorf("Chunk 2 header should suggest next offset=20, got: %s", result2.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Read the final chunk (remaining 6 bytes) ---
|
// Step 3: Read the final chunk (remaining 6 bytes) ---
|
||||||
// We ask for 10 bytes, but only 6 are left in the file
|
|
||||||
args3 := map[string]any{
|
args3 := map[string]any{
|
||||||
"path": testFile,
|
"path": testFile,
|
||||||
"offset": 20,
|
"offset": 20,
|
||||||
|
|
@ -795,16 +819,12 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
|
||||||
t.Fatalf("Chunk 3 failed: %s", result3.ForLLM)
|
t.Fatalf("Chunk 3 failed: %s", result3.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expect the last 6 characters
|
|
||||||
if !strings.Contains(result3.ForLLM, "uvwxyz") {
|
if !strings.Contains(result3.ForLLM, "uvwxyz") {
|
||||||
t.Errorf("Chunk 3 should contain 'uvwxyz', got: %s", result3.ForLLM)
|
t.Errorf("Chunk 3 should contain 'uvwxyz', got: %s", result3.ForLLM)
|
||||||
}
|
}
|
||||||
// Expect the header to indicate the end of the file
|
|
||||||
if !strings.Contains(result3.ForLLM, "[END OF FILE") {
|
if !strings.Contains(result3.ForLLM, "[END OF FILE") {
|
||||||
t.Errorf("Chunk 3 header should indicate end of file, got: %s", result3.ForLLM)
|
t.Errorf("Chunk 3 header should indicate end of file, got: %s", result3.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure no TRUNCATED message is present in the final chunk
|
|
||||||
if strings.Contains(result3.ForLLM, "[TRUNCATED") {
|
if strings.Contains(result3.ForLLM, "[TRUNCATED") {
|
||||||
t.Errorf("Chunk 3 header should NOT indicate truncation, got: %s", result3.ForLLM)
|
t.Errorf("Chunk 3 header should NOT indicate truncation, got: %s", result3.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
@ -816,7 +836,6 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
testFile := filepath.Join(tmpDir, "short.txt")
|
testFile := filepath.Join(tmpDir, "short.txt")
|
||||||
|
|
||||||
// create a file of only 5 bytes
|
|
||||||
err := os.WriteFile(testFile, []byte("12345"), 0o644)
|
err := os.WriteFile(testFile, []byte("12345"), 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to write test file: %v", err)
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
|
|
@ -827,19 +846,393 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) {
|
||||||
|
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
"path": testFile,
|
"path": testFile,
|
||||||
"offset": int64(100), // Offset beyond the end of the file
|
"offset": int64(100),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
// It should not be classified as a tool execution error
|
|
||||||
if result.IsError {
|
if result.IsError {
|
||||||
t.Errorf("A mistake was not expected, obtained IsError=true: %s", result.ForLLM)
|
t.Errorf("A mistake was not expected, obtained IsError=true: %s", result.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must return EXACTLY the string provided in the code
|
|
||||||
expectedMsg := "[END OF FILE - no content at this offset]"
|
expectedMsg := "[END OF FILE - no content at this offset]"
|
||||||
if result.ForLLM != expectedMsg {
|
if result.ForLLM != expectedMsg {
|
||||||
t.Errorf("The message %q was expected, obtained: %q", expectedMsg, result.ForLLM)
|
t.Errorf("The message %q was expected, obtained: %q", expectedMsg, result.ForLLM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadFileLinesTool_ChunkedReading(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "pagination_lines.txt")
|
||||||
|
|
||||||
|
fullContent := strings.Join([]string{
|
||||||
|
"line 1",
|
||||||
|
"line 2",
|
||||||
|
"line 3",
|
||||||
|
"line 4",
|
||||||
|
"line 5",
|
||||||
|
"line 6",
|
||||||
|
}, "\n") + "\n"
|
||||||
|
err := os.WriteFile(testFile, []byte(fullContent), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
|
||||||
|
|
||||||
|
result1 := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"start_line": 1,
|
||||||
|
"max_lines": 2,
|
||||||
|
})
|
||||||
|
if result1.IsError {
|
||||||
|
t.Fatalf("Chunk 1 failed: %s", result1.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result1.ForLLM, "1|line 1\n2|line 2\n") {
|
||||||
|
t.Fatalf("expected first two lines, got: %s", result1.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result1.ForLLM, "lines 1-2") {
|
||||||
|
t.Fatalf("expected line range 1-2, got: %s", result1.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result1.ForLLM, "start_line=3") {
|
||||||
|
t.Fatalf("expected continuation start_line=3, got: %s", result1.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result1.ForLLM, "max_lines=2") {
|
||||||
|
t.Fatalf("expected continuation max_lines=2, got: %s", result1.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
result2 := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"start_line": 3,
|
||||||
|
"max_lines": 2,
|
||||||
|
})
|
||||||
|
if result2.IsError {
|
||||||
|
t.Fatalf("Chunk 2 failed: %s", result2.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result2.ForLLM, "3|line 3\n4|line 4\n") {
|
||||||
|
t.Fatalf("expected middle chunk, got: %s", result2.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result2.ForLLM, "start_line=5") {
|
||||||
|
t.Fatalf("expected continuation start_line=5, got: %s", result2.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result2.ForLLM, "max_lines=2") {
|
||||||
|
t.Fatalf("expected continuation max_lines=2, got: %s", result2.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
result3 := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"start_line": 5,
|
||||||
|
"max_lines": 2,
|
||||||
|
})
|
||||||
|
if result3.IsError {
|
||||||
|
t.Fatalf("Chunk 3 failed: %s", result3.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result3.ForLLM, "5|line 5\n6|line 6\n") {
|
||||||
|
t.Fatalf("expected final chunk, got: %s", result3.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result3.ForLLM, "[END OF FILE") {
|
||||||
|
t.Fatalf("expected EOF marker, got: %s", result3.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFileLinesTool_DefaultOffsetAndRemainingLines(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "default_lines.txt")
|
||||||
|
|
||||||
|
err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"start_line": 1,
|
||||||
|
})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Execute() error = %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "1|line 1\n2|line 2\n3|line 3\n") {
|
||||||
|
t.Fatalf("expected remaining lines by default, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "lines 1-3") {
|
||||||
|
t.Fatalf("expected line range 1-3, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFileTool_LegacyLengthUsesByteModeForText(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "legacy_bytes.txt")
|
||||||
|
|
||||||
|
err := os.WriteFile(testFile, []byte("abcdefghijklmnopqrstuvwxyz"), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewReadFileBytesTool(tmpDir, false, MaxReadFileSize)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"offset": 10,
|
||||||
|
"length": 5,
|
||||||
|
})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Execute() error = %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "read: bytes 10-14") {
|
||||||
|
t.Fatalf("expected byte-based header, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "klmno") {
|
||||||
|
t.Fatalf("expected byte chunk content, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if strings.Contains(result.ForLLM, "lines ") {
|
||||||
|
t.Fatalf("expected legacy byte mode, got line-based header: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFileLinesTool_OffsetBeyondEOF(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "short_lines.txt")
|
||||||
|
|
||||||
|
err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"start_line": int64(100),
|
||||||
|
})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if result.ForLLM != "[END OF FILE - no content at or after start_line=100]" {
|
||||||
|
t.Fatalf("unexpected EOF message: %q", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFileLinesTool_RegistryValidationSupportsMaxLinesAndRejectsLimit(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "registry_lines.txt")
|
||||||
|
|
||||||
|
err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := NewToolRegistry()
|
||||||
|
reg.Register(NewReadFileLinesTool(tmpDir, false, MaxReadFileSize))
|
||||||
|
|
||||||
|
result := reg.Execute(context.Background(), "read_file", map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"start_line": 1,
|
||||||
|
"max_lines": 1,
|
||||||
|
})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected max_lines to pass registry validation, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "1|line 1\n") {
|
||||||
|
t.Fatalf("expected first line via max_lines, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = reg.Execute(context.Background(), "read_file", map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"start_line": 2,
|
||||||
|
"limit": 1,
|
||||||
|
})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "unexpected property \"limit\"") {
|
||||||
|
t.Fatalf("expected registry validation error for limit, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFileLinesTool_RejectsOffset(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "legacy_offset.txt")
|
||||||
|
|
||||||
|
err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"start_line": 1,
|
||||||
|
"offset": 1,
|
||||||
|
})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatalf("expected offset to be rejected, got success: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "offset is not supported in line mode; use start_line") {
|
||||||
|
t.Fatalf("unexpected error for offset in line mode: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFileLinesTool_RejectsLength(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "legacy_length.txt")
|
||||||
|
|
||||||
|
err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"start_line": 1,
|
||||||
|
"length": 1,
|
||||||
|
})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatalf("expected length to be rejected, got success: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "length is not supported in line mode; use max_lines") {
|
||||||
|
t.Fatalf("unexpected error for length in line mode: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFileLinesTool_RejectsLimit(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "legacy_limit.txt")
|
||||||
|
|
||||||
|
err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"start_line": 1,
|
||||||
|
"limit": 1,
|
||||||
|
})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "limit is not supported in line mode; use max_lines") {
|
||||||
|
t.Fatalf("unexpected error for limit in line mode: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFileLinesTool_BinaryFileRejected(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "binary.dat")
|
||||||
|
|
||||||
|
data := []byte{0x00, 0x01, 'A', 'B', 'C', 'D', 'E', 'F'}
|
||||||
|
err := os.WriteFile(testFile, data, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"start_line": 1,
|
||||||
|
})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatalf("expected binary file rejection in line mode, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "switch read_file mode to 'bytes'") {
|
||||||
|
t.Fatalf("expected binary file rejection message, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "mode to 'bytes'") {
|
||||||
|
t.Fatalf("expected suggestion to switch read_file mode, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFileLinesTool_TruncatesSingleLongLineAtByteBudget(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "long_line.txt")
|
||||||
|
|
||||||
|
content := "first line\n" + strings.Repeat("x", 70*1024) + "\n"
|
||||||
|
err := os.WriteFile(testFile, []byte(content), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"start_line": 1,
|
||||||
|
})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Execute() error = %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "was cut mid-line") {
|
||||||
|
t.Fatalf("expected explicit mid-line truncation warning, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "1|first line\n") {
|
||||||
|
t.Fatalf("expected the first line with line prefix, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "2|") {
|
||||||
|
t.Fatalf("expected line prefix for the truncated line, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFileLinesTool_NoTrailingNewline(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "no_trailing_newline.txt")
|
||||||
|
|
||||||
|
err := os.WriteFile(testFile, []byte("line 1\nline 2"), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"start_line": 1,
|
||||||
|
})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Execute() error = %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "1|line 1\n2|line 2") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected final line without trailing newline to be preserved, got: %s",
|
||||||
|
result.ForLLM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "[END OF FILE - no further content.]") {
|
||||||
|
t.Fatalf("expected EOF marker, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFileLinesTool_ExactByteBudgetBoundaryIncludesPrefix(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "exact_boundary.txt")
|
||||||
|
|
||||||
|
err := os.WriteFile(testFile, []byte("1234567\nsecond line\n"), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewReadFileLinesTool(tmpDir, false, 10)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"start_line": 1,
|
||||||
|
})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Execute() error = %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "1|1234567\n") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected first line to fit exactly in the byte budget with its prefix, got: %s",
|
||||||
|
result.ForLLM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if strings.Contains(result.ForLLM, "2|") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected second line to be excluded once the exact output byte budget was reached, got: %s",
|
||||||
|
result.ForLLM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "file_bytes: 8 | output_bytes: 10") {
|
||||||
|
t.Fatalf("expected separate file/output byte counters, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "start_line=2") {
|
||||||
|
t.Fatalf("expected continuation at line 2, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
163
pkg/tools/load_image.go
Normal file
163
pkg/tools/load_image.go
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadImageTool loads a local image file into the MediaStore and returns a
|
||||||
|
// media:// reference. The agent loop's resolveMediaRefs will then base64-encode
|
||||||
|
// it and attach it as an image_url part in the next LLM request, enabling
|
||||||
|
// vision on local files — the same pipeline used when a user sends an image
|
||||||
|
// through a chat channel.
|
||||||
|
//
|
||||||
|
// This is intentionally different from SendFileTool:
|
||||||
|
// - SendFileTool → MediaResult + WithResponseHandled() → sends file to user, ends turn
|
||||||
|
// - LoadImageTool → plain ToolResult with media:// in ForLLM → LLM sees the image next turn
|
||||||
|
type LoadImageTool struct {
|
||||||
|
workspace string
|
||||||
|
restrict bool
|
||||||
|
maxFileSize int
|
||||||
|
mediaStore media.MediaStore
|
||||||
|
allowPaths []*regexp.Regexp
|
||||||
|
|
||||||
|
defaultChannel string
|
||||||
|
defaultChatID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLoadImageTool(
|
||||||
|
workspace string,
|
||||||
|
restrict bool,
|
||||||
|
maxFileSize int,
|
||||||
|
store media.MediaStore,
|
||||||
|
allowPaths ...[]*regexp.Regexp,
|
||||||
|
) *LoadImageTool {
|
||||||
|
if maxFileSize <= 0 {
|
||||||
|
maxFileSize = config.DefaultMaxMediaSize
|
||||||
|
}
|
||||||
|
var patterns []*regexp.Regexp
|
||||||
|
if len(allowPaths) > 0 {
|
||||||
|
patterns = allowPaths[0]
|
||||||
|
}
|
||||||
|
return &LoadImageTool{
|
||||||
|
workspace: workspace,
|
||||||
|
restrict: restrict,
|
||||||
|
maxFileSize: maxFileSize,
|
||||||
|
mediaStore: store,
|
||||||
|
allowPaths: patterns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *LoadImageTool) Name() string { return "load_image" }
|
||||||
|
|
||||||
|
func (t *LoadImageTool) Description() string {
|
||||||
|
return "Load a local image file so you can analyze its contents with vision. " +
|
||||||
|
"Supported formats: JPEG, PNG, GIF, WebP, BMP. " +
|
||||||
|
"After calling this tool, describe or analyze the image in your next response."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *LoadImageTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"path": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to the local image file. Relative paths are resolved from workspace.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"path"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *LoadImageTool) SetContext(channel, chatID string) {
|
||||||
|
t.defaultChannel = channel
|
||||||
|
t.defaultChatID = chatID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *LoadImageTool) SetMediaStore(store media.MediaStore) {
|
||||||
|
t.mediaStore = store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *LoadImageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return ErrorResult("path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer context-injected channel/chatID (set by ExecuteWithContext), fall back to SetContext values.
|
||||||
|
channel := ToolChannel(ctx)
|
||||||
|
if channel == "" {
|
||||||
|
channel = t.defaultChannel
|
||||||
|
}
|
||||||
|
chatID := ToolChatID(ctx)
|
||||||
|
if chatID == "" {
|
||||||
|
chatID = t.defaultChatID
|
||||||
|
}
|
||||||
|
if channel == "" || chatID == "" {
|
||||||
|
return ErrorResult("no target channel/chat available")
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.mediaStore == nil {
|
||||||
|
return ErrorResult("media store not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := validatePathWithAllowPaths(path, t.workspace, t.restrict, t.allowPaths)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("invalid path: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("file not found: %v", err))
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return ErrorResult("path is a directory, expected an image file")
|
||||||
|
}
|
||||||
|
if info.Size() > int64(t.maxFileSize) {
|
||||||
|
return ErrorResult(fmt.Sprintf(
|
||||||
|
"file too large: %d bytes (max %d bytes)", info.Size(), t.maxFileSize,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect MIME type — reuse the helper already in send_file.go
|
||||||
|
mediaType := detectMediaType(resolved)
|
||||||
|
if !strings.HasPrefix(mediaType, "image/") {
|
||||||
|
return ErrorResult(fmt.Sprintf(
|
||||||
|
"file does not appear to be an image (detected type: %s)", mediaType,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := filepath.Base(resolved)
|
||||||
|
scope := fmt.Sprintf("tool:load_image:%s:%s", channel, chatID)
|
||||||
|
|
||||||
|
ref, err := t.mediaStore.Store(resolved, media.MediaMeta{
|
||||||
|
Filename: filename,
|
||||||
|
ContentType: mediaType,
|
||||||
|
Source: "tool:load_image",
|
||||||
|
CleanupPolicy: media.CleanupPolicyForgetOnly,
|
||||||
|
}, scope)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to register image in media store: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the tool result text. The media:// ref will be picked up by
|
||||||
|
// resolveMediaRefs in loop_media.go and converted to a base64 data URL
|
||||||
|
// before the next LLM call, exactly like channel-received images.
|
||||||
|
msg := fmt.Sprintf("Image loaded: %s\n[image: %s]", filename, ref)
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: msg,
|
||||||
|
ForUser: fmt.Sprintf("Loaded image: %s", filename),
|
||||||
|
// Media refs inside ForLLM are resolved by resolveMediaRefs in the
|
||||||
|
// agent loop before the next LLM call. Do NOT use MediaResult here —
|
||||||
|
// that would send the file to the user channel instead.
|
||||||
|
Media: []string{ref},
|
||||||
|
}
|
||||||
|
}
|
||||||
174
pkg/tools/load_image_test.go
Normal file
174
pkg/tools/load_image_test.go
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadImage_PathRequired(t *testing.T) {
|
||||||
|
tool := NewLoadImageTool("/tmp", false, 0, nil)
|
||||||
|
ctx := WithToolContext(context.Background(), "test", "chat1")
|
||||||
|
result := tool.Execute(ctx, map[string]any{})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatal("expected error for missing path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadImage_NilMediaStore(t *testing.T) {
|
||||||
|
tool := NewLoadImageTool("/tmp", false, 0, nil)
|
||||||
|
ctx := WithToolContext(context.Background(), "test", "chat1")
|
||||||
|
result := tool.Execute(ctx, map[string]any{"path": "test.png"})
|
||||||
|
if !result.IsError || result.ForLLM != "media store not configured" {
|
||||||
|
t.Fatalf("expected media store error, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadImage_NoChannelContext(t *testing.T) {
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
tool := NewLoadImageTool("/tmp", false, 0, store)
|
||||||
|
// No WithToolContext — should fail
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"path": "test.png"})
|
||||||
|
if !result.IsError || result.ForLLM != "no target channel/chat available" {
|
||||||
|
t.Fatalf("expected channel error, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadImage_NonImageFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
txtFile := filepath.Join(dir, "readme.txt")
|
||||||
|
os.WriteFile(txtFile, []byte("hello"), 0o644)
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
tool := NewLoadImageTool(dir, false, 0, store)
|
||||||
|
ctx := WithToolContext(context.Background(), "test", "chat1")
|
||||||
|
result := tool.Execute(ctx, map[string]any{"path": txtFile})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatal("expected error for non-image file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadImage_DefaultMaxSize(t *testing.T) {
|
||||||
|
tool := NewLoadImageTool("/tmp", false, 0, nil)
|
||||||
|
if tool.maxFileSize != config.DefaultMaxMediaSize {
|
||||||
|
t.Errorf("expected default max size %d, got %d", config.DefaultMaxMediaSize, tool.maxFileSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadImage_FileTooLarge(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
bigFile := filepath.Join(dir, "big.png")
|
||||||
|
// Create a file with PNG header but exceeding max size
|
||||||
|
data := make([]byte, 1024)
|
||||||
|
copy(data, []byte{0x89, 0x50, 0x4E, 0x47}) // PNG magic bytes
|
||||||
|
os.WriteFile(bigFile, data, 0o644)
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
tool := NewLoadImageTool(dir, false, 512, store) // maxSize = 512
|
||||||
|
ctx := WithToolContext(context.Background(), "test", "chat1")
|
||||||
|
result := tool.Execute(ctx, map[string]any{"path": bigFile})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatal("expected error for oversized file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubagentManager_SetMediaResolver_StoresResolver(t *testing.T) {
|
||||||
|
manager := NewSubagentManager(nil, "gpt-test", "/tmp")
|
||||||
|
|
||||||
|
called := false
|
||||||
|
manager.SetMediaResolver(func(msgs []providers.Message) []providers.Message {
|
||||||
|
called = true
|
||||||
|
return msgs
|
||||||
|
})
|
||||||
|
|
||||||
|
manager.mu.RLock()
|
||||||
|
got := manager.mediaResolver
|
||||||
|
manager.mu.RUnlock()
|
||||||
|
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected mediaResolver to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if called {
|
||||||
|
t.Fatal("resolver should not be called during SetMediaResolver")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadImage_SuccessPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a minimal valid PNG file (8-byte signature + minimal IHDR + IEND).
|
||||||
|
// The PNG spec requires the 8-byte magic header: 0x89 P N G \r \n 0x1a \n
|
||||||
|
pngSignature := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
||||||
|
// IHDR chunk: length(13) + "IHDR" + 1x1 px, 8-bit RGB, no interlace + CRC
|
||||||
|
ihdr := []byte{
|
||||||
|
0x00, 0x00, 0x00, 0x0D, // chunk length = 13
|
||||||
|
0x49, 0x48, 0x44, 0x52, // "IHDR"
|
||||||
|
0x00, 0x00, 0x00, 0x01, // width = 1
|
||||||
|
0x00, 0x00, 0x00, 0x01, // height = 1
|
||||||
|
0x08, // bit depth = 8
|
||||||
|
0x02, // color type = RGB
|
||||||
|
0x00, 0x00, 0x00, // compression, filter, interlace
|
||||||
|
0x90, 0x77, 0x53, 0xDE, // CRC (valid for this IHDR)
|
||||||
|
}
|
||||||
|
// IEND chunk
|
||||||
|
iend := []byte{
|
||||||
|
0x00, 0x00, 0x00, 0x00, // chunk length = 0
|
||||||
|
0x49, 0x45, 0x4E, 0x44, // "IEND"
|
||||||
|
0xAE, 0x42, 0x60, 0x82, // CRC
|
||||||
|
}
|
||||||
|
|
||||||
|
pngData := make([]byte, 0, len(pngSignature)+len(ihdr)+len(iend))
|
||||||
|
pngData = append(pngData, pngSignature...)
|
||||||
|
pngData = append(pngData, ihdr...)
|
||||||
|
pngData = append(pngData, iend...)
|
||||||
|
|
||||||
|
imgPath := filepath.Join(dir, "test_image.png")
|
||||||
|
if err := os.WriteFile(imgPath, pngData, 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to create test PNG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
tool := NewLoadImageTool(dir, false, 0, store)
|
||||||
|
ctx := WithToolContext(context.Background(), "test", "chat1")
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, map[string]any{"path": imgPath})
|
||||||
|
|
||||||
|
// 1. Must not be an error
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Media must contain exactly one media:// ref
|
||||||
|
if len(result.Media) != 1 {
|
||||||
|
t.Fatalf("expected 1 media ref, got %d", len(result.Media))
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(result.Media[0], "media://") {
|
||||||
|
t.Errorf("expected media ref to start with 'media://', got: %s", result.Media[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. ForLLM must contain the [image: marker
|
||||||
|
if !strings.Contains(result.ForLLM, "[image:") {
|
||||||
|
t.Errorf("expected ForLLM to contain '[image:' marker, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. ForLLM should also contain the media:// ref
|
||||||
|
if !strings.Contains(result.ForLLM, result.Media[0]) {
|
||||||
|
t.Errorf("expected ForLLM to contain media ref %q, got: %s", result.Media[0], result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Verify the ref is resolvable in the store
|
||||||
|
resolved, err := store.Resolve(result.Media[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("media ref not resolvable: %v", err)
|
||||||
|
}
|
||||||
|
if resolved != imgPath {
|
||||||
|
t.Errorf("expected resolved path %q, got %q", imgPath, resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -228,6 +228,7 @@ func (r *ToolRegistry) ExecuteWithContext(
|
||||||
func() {
|
func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
if re := recover(); re != nil {
|
if re := recover(); re != nil {
|
||||||
|
logger.RecoverPanicNoExit(re)
|
||||||
errMsg := fmt.Sprintf("Tool '%s' crashed with panic: %v", name, re)
|
errMsg := fmt.Sprintf("Tool '%s' crashed with panic: %v", name, re)
|
||||||
logger.ErrorCF("tool", "Tool execution panic recovered",
|
logger.ErrorCF("tool", "Tool execution panic recovered",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,12 @@ type SubagentManager struct {
|
||||||
hasTemperature bool
|
hasTemperature bool
|
||||||
nextID int
|
nextID int
|
||||||
spawner SpawnSubTurnFunc
|
spawner SpawnSubTurnFunc
|
||||||
|
|
||||||
|
// mediaResolver resolves media:// refs in tool-loop messages before
|
||||||
|
// each LLM call in the legacy RunToolLoop fallback path.
|
||||||
|
// This lets subagents reuse the same media handling behavior as the
|
||||||
|
// main agent loop without importing pkg/agent and creating a cycle.
|
||||||
|
mediaResolver func([]providers.Message) []providers.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSubagentManager(
|
func NewSubagentManager(
|
||||||
|
|
@ -90,6 +96,17 @@ func (sm *SubagentManager) SetSpawner(spawner SpawnSubTurnFunc) {
|
||||||
sm.spawner = spawner
|
sm.spawner = spawner
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMediaResolver injects a message preprocessor that resolves media:// refs
|
||||||
|
// into LLM-ready content before each tool-loop iteration.
|
||||||
|
// This is only used by the legacy RunToolLoop fallback path.
|
||||||
|
func (sm *SubagentManager) SetMediaResolver(
|
||||||
|
resolver func([]providers.Message) []providers.Message,
|
||||||
|
) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
sm.mediaResolver = resolver
|
||||||
|
}
|
||||||
|
|
||||||
// SetLLMOptions sets max tokens and temperature for subagent LLM calls.
|
// SetLLMOptions sets max tokens and temperature for subagent LLM calls.
|
||||||
func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
|
func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
|
|
@ -177,6 +194,7 @@ func (sm *SubagentManager) runTask(
|
||||||
temperature := sm.temperature
|
temperature := sm.temperature
|
||||||
hasMaxTokens := sm.hasMaxTokens
|
hasMaxTokens := sm.hasMaxTokens
|
||||||
hasTemperature := sm.hasTemperature
|
hasTemperature := sm.hasTemperature
|
||||||
|
mediaResolver := sm.mediaResolver
|
||||||
sm.mu.RUnlock()
|
sm.mu.RUnlock()
|
||||||
|
|
||||||
var result *ToolResult
|
var result *ToolResult
|
||||||
|
|
@ -223,6 +241,7 @@ After completing the task, provide a clear summary of what was done.`
|
||||||
Tools: tools,
|
Tools: tools,
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
LLMOptions: llmOptions,
|
LLMOptions: llmOptions,
|
||||||
|
MediaResolver: mediaResolver,
|
||||||
}, messages, task.OriginChannel, task.OriginChatID)
|
}, messages, task.OriginChannel, task.OriginChatID)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,11 @@ type ToolLoopConfig struct {
|
||||||
Tools *ToolRegistry
|
Tools *ToolRegistry
|
||||||
MaxIterations int
|
MaxIterations int
|
||||||
LLMOptions map[string]any
|
LLMOptions map[string]any
|
||||||
|
|
||||||
|
// MediaResolver resolves media:// refs in messages before each LLM call.
|
||||||
|
// This is optional and is mainly used by subagent legacy fallback execution
|
||||||
|
// so subagents can reuse the same multimodal media handling as the main loop.
|
||||||
|
MediaResolver func(messages []providers.Message) []providers.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToolLoopResult contains the result of running the tool loop.
|
// ToolLoopResult contains the result of running the tool loop.
|
||||||
|
|
@ -63,8 +68,27 @@ func RunToolLoop(
|
||||||
if llmOpts == nil {
|
if llmOpts == nil {
|
||||||
llmOpts = map[string]any{}
|
llmOpts = map[string]any{}
|
||||||
}
|
}
|
||||||
// 3. Call LLM
|
|
||||||
response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)
|
// 3. Resolve media:// refs and Call LLM.
|
||||||
|
// Tools like load_image produce media:// refs in their result messages.
|
||||||
|
// Without this step, the LLM would receive raw "media://uuid" strings
|
||||||
|
// instead of base64-encoded image data URLs.
|
||||||
|
//
|
||||||
|
// We build a separate callMessages slice so that:
|
||||||
|
// (a) the resolver output is used for the LLM call only,
|
||||||
|
// (b) the original `messages` slice keeps the unresolved refs for
|
||||||
|
// subsequent iterations — the resolver is idempotent but working
|
||||||
|
// on the original avoids double-encoding issues.
|
||||||
|
//
|
||||||
|
// On iteration 1 the initial user messages typically have no media://
|
||||||
|
// refs (they come from plain text), so this is effectively a no-op;
|
||||||
|
// it becomes relevant from iteration 2 onward when tool results may
|
||||||
|
// contain media refs.
|
||||||
|
callMessages := messages
|
||||||
|
if config.MediaResolver != nil && iteration > 1 {
|
||||||
|
callMessages = config.MediaResolver(messages)
|
||||||
|
}
|
||||||
|
response, err := config.Provider.Chat(ctx, callMessages, providerToolDefs, config.Model, llmOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("toolloop", "LLM call failed",
|
logger.ErrorCF("toolloop", "LLM call failed",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
|
|
@ -161,11 +185,15 @@ func RunToolLoop(
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
contentForLLM := r.result.ContentForLLM()
|
contentForLLM := r.result.ContentForLLM()
|
||||||
|
|
||||||
messages = append(messages, providers.Message{
|
toolMsg := providers.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
Content: contentForLLM,
|
Content: contentForLLM,
|
||||||
ToolCallID: r.tc.ID,
|
ToolCallID: r.tc.ID,
|
||||||
})
|
}
|
||||||
|
if len(r.result.Media) > 0 && !r.result.ResponseHandled {
|
||||||
|
toolMsg.Media = append(toolMsg.Media, r.result.Media...)
|
||||||
|
}
|
||||||
|
messages = append(messages, toolMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
707
pkg/updater/updater.go
Normal file
707
pkg/updater/updater.go
Normal file
|
|
@ -0,0 +1,707 @@
|
||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"archive/zip"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/minio/selfupdate"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// httpClient is a shared HTTP client used for release checks and downloads.
|
||||||
|
// The Timeout value applies to the entire HTTP request: dialing, TLS
|
||||||
|
// handshake, redirects, and reading the response body. It is NOT only
|
||||||
|
// a connection (dial) timeout. To control lower-level timeouts (dial,
|
||||||
|
// TLS handshake, response header wait), supply a custom Transport with
|
||||||
|
// an appropriately configured net.Dialer.
|
||||||
|
var httpClient = &http.Client{Timeout: 2 * time.Minute}
|
||||||
|
|
||||||
|
// DownloadAndExtractRelease downloads a release archive (or uses a direct
|
||||||
|
// asset URL) and extracts it to a temporary directory. It returns the
|
||||||
|
// extraction directory on success. If releaseURL is empty, the latest
|
||||||
|
// release of the current project is used. platform/arch can be used to
|
||||||
|
// select the correct asset (e.g. "linux", "amd64").
|
||||||
|
func DownloadAndExtractRelease(releaseURL, platform, arch string) (string, error) {
|
||||||
|
assetURL, checksum, err := findAssetInfo(releaseURL, platform, arch)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download asset to temp file. Use the asset URL extension so
|
||||||
|
// extractArchive can detect the archive format (zip/tar.gz/tar).
|
||||||
|
tmpPattern := "picoclaw-release-*"
|
||||||
|
if u, perr := url.Parse(assetURL); perr == nil {
|
||||||
|
base := filepath.Base(u.Path)
|
||||||
|
lbase := strings.ToLower(base)
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(lbase, ".zip"):
|
||||||
|
tmpPattern += ".zip"
|
||||||
|
case strings.HasSuffix(lbase, ".tar.gz") || strings.HasSuffix(lbase, ".tgz"):
|
||||||
|
tmpPattern += ".tar.gz"
|
||||||
|
case strings.HasSuffix(lbase, ".tar"):
|
||||||
|
tmpPattern += ".tar"
|
||||||
|
default:
|
||||||
|
tmpPattern += ".archive"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tmpPattern += ".archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp("", tmpPattern)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
defer tmpFile.Close()
|
||||||
|
|
||||||
|
resp, err := httpClient.Get(assetURL)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", fmt.Errorf("failed to download asset: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream download while computing SHA256 to avoid a second download.
|
||||||
|
// Also show a simple progress line to stderr so users see activity.
|
||||||
|
h := sha256.New()
|
||||||
|
pw := &progressWriter{total: resp.ContentLength}
|
||||||
|
mw := io.MultiWriter(tmpFile, h, pw)
|
||||||
|
if _, err = io.Copy(mw, resp.Body); err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// ensure final progress line ends with newline
|
||||||
|
pw.Finish()
|
||||||
|
|
||||||
|
// verify checksum if available
|
||||||
|
if checksum != "" {
|
||||||
|
got := hex.EncodeToString(h.Sum(nil))
|
||||||
|
if !strings.EqualFold(got, checksum) {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return "", fmt.Errorf("checksum mismatch: got %s expected %s", got, checksum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract
|
||||||
|
destDir, err := os.MkdirTemp("", "picoclaw-extract-*")
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := extractArchive(tmpPath, destDir); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
os.RemoveAll(destDir)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup archive file; keep extracted contents
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return destDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSelfFromRelease downloads the release matching the given parameters,
|
||||||
|
// extracts it and applies the binary named programName to update the
|
||||||
|
// currently running executable using minio/selfupdate.
|
||||||
|
// If releaseURL is empty, the latest release is used. If platform or arch
|
||||||
|
// is empty, runtime values are used.
|
||||||
|
func UpdateSelfFromRelease(releaseURL, platform, arch, programName string) error {
|
||||||
|
if platform == "" {
|
||||||
|
platform = runtime.GOOS
|
||||||
|
}
|
||||||
|
if arch == "" {
|
||||||
|
arch = runtime.GOARCH
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, err := DownloadAndExtractRelease(releaseURL, platform, arch)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
|
binPath, err := findBinaryInDir(dir, programName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure executable bit on non-windows
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
_ = os.Chmod(binPath, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(binPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Backup current executable so we can roll back if needed.
|
||||||
|
var opts selfupdate.Options
|
||||||
|
if exePath, err := os.Executable(); err == nil {
|
||||||
|
opts.OldSavePath = exePath + ".old"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := selfupdate.Apply(f, opts); err != nil {
|
||||||
|
return fmt.Errorf("apply update: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSelf updates the running executable by fetching the latest release
|
||||||
|
// and applying the binary matching programName.
|
||||||
|
func UpdateSelf(programName string) error {
|
||||||
|
// By default, select the latest stable release when no explicit
|
||||||
|
// release URL is provided. Use --nightly or a custom URL to override.
|
||||||
|
return UpdateSelfFromRelease("", runtime.GOOS, runtime.GOARCH, programName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReleaseAPIURL returns the GitHub Releases API URL for the given repo owner.
|
||||||
|
// Example: owner="sky5454" -> https://api.github.com/repos/sky5454/picoclaw/releases/latest
|
||||||
|
func GetReleaseAPIURL(owner string) string {
|
||||||
|
return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/latest", owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProdReleaseAPIURL returns the production release API URL (upstream).
|
||||||
|
func GetProdReleaseAPIURL() string {
|
||||||
|
return GetReleaseAPIURL("sipeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReleaseTagAPIURL returns the GitHub Releases API URL for a specific tag.
|
||||||
|
// Example: owner="sipeed", tag="nightly" -> https://api.github.com/repos/sipeed/picoclaw/releases/tags/nightly
|
||||||
|
func GetReleaseTagAPIURL(owner, tag string) string {
|
||||||
|
return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/tags/%s", owner, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNightlyReleaseAPIURL returns the nightly release API URL for the production repo.
|
||||||
|
func GetNightlyReleaseAPIURL() string {
|
||||||
|
return GetReleaseTagAPIURL("sipeed", "nightly")
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAssetURL resolves the appropriate asset URL for the given release
|
||||||
|
// selector. It accepts direct archive URLs as well as GitHub release URLs
|
||||||
|
// or empty (latest release for the project).
|
||||||
|
func findAssetInfo(releaseURL, platform, arch string) (string, string, error) {
|
||||||
|
// returns (assetURL, sha256ChecksumHex, error)
|
||||||
|
if looksLikeDirectAssetURL(releaseURL) {
|
||||||
|
return "", "", fmt.Errorf("no checksum found for asset %s", releaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := buildReleaseAPIURL(releaseURL)
|
||||||
|
if apiURL == "" {
|
||||||
|
// If caller provided an empty releaseURL, default to the
|
||||||
|
// production latest release API URL (stable release).
|
||||||
|
apiURL = GetProdReleaseAPIURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := httpClient.Get(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", "", fmt.Errorf("failed to query releases: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Assets []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
BrowserDownloadURL string `json:"browser_download_url"`
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
} `json:"assets"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection order: platform -> arch -> extension.
|
||||||
|
platformLower := strings.ToLower(platform)
|
||||||
|
archLower := strings.ToLower(arch)
|
||||||
|
|
||||||
|
isZip := func(name string) bool {
|
||||||
|
return strings.HasSuffix(name, ".zip")
|
||||||
|
}
|
||||||
|
isTarGz := func(name string) bool {
|
||||||
|
return strings.HasSuffix(name, ".tar.gz") || strings.HasSuffix(name, ".tgz")
|
||||||
|
}
|
||||||
|
isTar := func(name string) bool { return strings.HasSuffix(name, ".tar") }
|
||||||
|
|
||||||
|
// collect indices of assets that contain platform (if provided)
|
||||||
|
var platformIdx []int
|
||||||
|
for i, a := range data.Assets {
|
||||||
|
n := strings.ToLower(a.Name)
|
||||||
|
if platform == "" || strings.Contains(n, platformLower) {
|
||||||
|
platformIdx = append(platformIdx, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pickBest := func(idxs []int) (string, int, bool) {
|
||||||
|
if len(idxs) == 0 {
|
||||||
|
return "", -1, false
|
||||||
|
}
|
||||||
|
// prefer arch matches within idxs; if arch was specified but
|
||||||
|
// no arch match exists among idxs, treat as no candidate.
|
||||||
|
var archIdx []int
|
||||||
|
if arch != "" {
|
||||||
|
aliases := archAliases(archLower)
|
||||||
|
for _, i := range idxs {
|
||||||
|
n := strings.ToLower(data.Assets[i].Name)
|
||||||
|
for _, ali := range aliases {
|
||||||
|
if strings.Contains(n, ali) {
|
||||||
|
archIdx = append(archIdx, i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(archIdx) == 0 {
|
||||||
|
return "", -1, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates := archIdx
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
candidates = idxs
|
||||||
|
}
|
||||||
|
|
||||||
|
// extension preference
|
||||||
|
if platformLower == "windows" {
|
||||||
|
// prefer .zip only
|
||||||
|
for _, i := range candidates {
|
||||||
|
if isZip(strings.ToLower(data.Assets[i].Name)) {
|
||||||
|
return data.Assets[i].BrowserDownloadURL, i, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if no zip found, fallthrough to first candidate
|
||||||
|
return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// non-windows: prefer tar.gz/tgz, then tar, then zip
|
||||||
|
for _, i := range candidates {
|
||||||
|
if isTarGz(strings.ToLower(data.Assets[i].Name)) {
|
||||||
|
return data.Assets[i].BrowserDownloadURL, i, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, i := range candidates {
|
||||||
|
if isTar(strings.ToLower(data.Assets[i].Name)) {
|
||||||
|
return data.Assets[i].BrowserDownloadURL, i, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, i := range candidates {
|
||||||
|
if isZip(strings.ToLower(data.Assets[i].Name)) {
|
||||||
|
return data.Assets[i].BrowserDownloadURL, i, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fallback to first candidate
|
||||||
|
return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try platform matches first
|
||||||
|
if url, idx, ok := pickBest(platformIdx); ok {
|
||||||
|
// attempt to find checksum: prefer asset digest from API if present
|
||||||
|
if d := strings.TrimSpace(data.Assets[idx].Digest); d != "" {
|
||||||
|
dLower := strings.ToLower(d)
|
||||||
|
if strings.HasPrefix(dLower, "sha256:") {
|
||||||
|
hexpart := strings.TrimPrefix(dLower, "sha256:")
|
||||||
|
return url, hexpart, nil
|
||||||
|
}
|
||||||
|
// If digest already looks like a 64-hex, return it
|
||||||
|
if ok, _ := regexp.MatchString("(?i)^[a-f0-9]{64}$", dLower); ok {
|
||||||
|
return url, dLower, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Look for checksum assets and verify by computing the asset's sha256.
|
||||||
|
for j, a := range data.Assets {
|
||||||
|
n := strings.ToLower(a.Name)
|
||||||
|
if strings.Contains(n, "sha256") ||
|
||||||
|
strings.Contains(n, "sha256sum") ||
|
||||||
|
strings.Contains(n, "checksums") ||
|
||||||
|
strings.HasSuffix(n, ".sha256") ||
|
||||||
|
strings.HasSuffix(n, ".sha256sum") {
|
||||||
|
resp2, err := httpClient.Get(data.Assets[j].BrowserDownloadURL)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bs, err := io.ReadAll(resp2.Body)
|
||||||
|
resp2.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if h, ok := findHashInChecksumContent(bs, url); ok {
|
||||||
|
return url, h, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No checksum found for the selected platform asset -> error
|
||||||
|
return "", "", fmt.Errorf("no checksum found for asset %s", url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No platform match — require explicit platform+arch; fail fast.
|
||||||
|
return "", "", fmt.Errorf("no release asset matching platform %q and arch %q", platform, arch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeDirectAssetURL(u string) bool {
|
||||||
|
if u == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(u)
|
||||||
|
if strings.HasSuffix(lower, ".zip") ||
|
||||||
|
strings.HasSuffix(lower, ".tar.gz") ||
|
||||||
|
strings.HasSuffix(lower, ".tgz") ||
|
||||||
|
strings.HasSuffix(lower, ".tar") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(lower, "/releases/download/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildReleaseAPIURL(releaseURL string) string {
|
||||||
|
if releaseURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(releaseURL, "api.github.com") {
|
||||||
|
return releaseURL
|
||||||
|
}
|
||||||
|
u, err := url.Parse(releaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if u.Host != "github.com" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
owner := parts[0]
|
||||||
|
repo := parts[1]
|
||||||
|
// if tag specified
|
||||||
|
if len(parts) >= 5 && parts[2] == "releases" && parts[3] == "tag" {
|
||||||
|
tag := parts[4]
|
||||||
|
return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, tag)
|
||||||
|
}
|
||||||
|
// default to latest
|
||||||
|
return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: helper functions to compute SHA256 from URL/path were removed
|
||||||
|
// after refactoring to stream the download and verify the checksum
|
||||||
|
// during the single download to avoid double-transfer.
|
||||||
|
|
||||||
|
// findHashInChecksumContent attempts to locate a 64-hex SHA256 in the
|
||||||
|
// checksum file content that corresponds to assetURL. It returns the
|
||||||
|
// found hash (lowercase) and true, or "", false if not found.
|
||||||
|
func findHashInChecksumContent(bs []byte, assetURL string) (string, bool) {
|
||||||
|
s := strings.ToLower(string(bs))
|
||||||
|
var assetBase string
|
||||||
|
if u, err := url.Parse(assetURL); err == nil {
|
||||||
|
assetBase = strings.ToLower(filepath.Base(u.Path))
|
||||||
|
} else {
|
||||||
|
assetBase = strings.ToLower(filepath.Base(assetURL))
|
||||||
|
}
|
||||||
|
re := regexp.MustCompile(`(?i)\b([a-f0-9]{64})\b`)
|
||||||
|
// prefer a line containing the asset filename
|
||||||
|
for _, line := range strings.Split(s, "\n") {
|
||||||
|
if strings.Contains(line, assetBase) {
|
||||||
|
if m := re.FindString(line); m != "" {
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fallback: if there's exactly one unique 64-hex value, return it
|
||||||
|
matches := re.FindAllString(s, -1)
|
||||||
|
uniq := map[string]struct{}{}
|
||||||
|
for _, m := range matches {
|
||||||
|
uniq[m] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(uniq) == 1 {
|
||||||
|
for k := range uniq {
|
||||||
|
return k, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// progressWriter implements io.Writer and prints a simple progress
|
||||||
|
// line to stderr while bytes are written. It is intended to be used
|
||||||
|
// as one writer in an io.MultiWriter so we can stream-to-disk, compute
|
||||||
|
// the sha256, and update the progress display in a single pass.
|
||||||
|
type progressWriter struct {
|
||||||
|
total int64
|
||||||
|
written int64
|
||||||
|
last time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pw *progressWriter) Write(p []byte) (int, error) {
|
||||||
|
n := len(p)
|
||||||
|
pw.written += int64(n)
|
||||||
|
now := time.Now()
|
||||||
|
if pw.last.IsZero() || now.Sub(pw.last) >= 200*time.Millisecond || (pw.total > 0 && pw.written == pw.total) {
|
||||||
|
pw.print()
|
||||||
|
pw.last = now
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pw *progressWriter) print() {
|
||||||
|
if pw.total > 0 {
|
||||||
|
pct := float64(pw.written) * 100.0 / float64(pw.total)
|
||||||
|
fmt.Fprintf(os.Stderr, "\rDownloading: %s / %s (%.1f%%)", humanBytes(pw.written), humanBytes(pw.total), pct)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "\rDownloading: %s", humanBytes(pw.written))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pw *progressWriter) Finish() {
|
||||||
|
pw.print()
|
||||||
|
fmt.Fprintln(os.Stderr, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanBytes(n int64) string {
|
||||||
|
f := float64(n)
|
||||||
|
const (
|
||||||
|
KB = 1024.0
|
||||||
|
MB = KB * 1024.0
|
||||||
|
GB = MB * 1024.0
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case f >= GB:
|
||||||
|
return fmt.Sprintf("%.2f GB", f/GB)
|
||||||
|
case f >= MB:
|
||||||
|
return fmt.Sprintf("%.2f MB", f/MB)
|
||||||
|
case f >= KB:
|
||||||
|
return fmt.Sprintf("%.2f KB", f/KB)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d B", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// archAliases returns common name variants for an architecture string
|
||||||
|
// so we can match release asset names like "x86_64" vs Go's "amd64".
|
||||||
|
// archAliases returns name variants for an architecture string.
|
||||||
|
// If `arch` is empty or matches the local runtime.GOARCH, prefer the
|
||||||
|
// compile-time architecture aliases provided by archAliasesForLocal
|
||||||
|
// (implemented per-architecture via build tags). For other `arch`
|
||||||
|
// values we use a small synonyms map.
|
||||||
|
func archAliases(arch string) []string {
|
||||||
|
a := strings.ToLower(arch)
|
||||||
|
if syns, ok := archSynonyms[a]; ok {
|
||||||
|
return syns
|
||||||
|
}
|
||||||
|
return []string{a}
|
||||||
|
}
|
||||||
|
|
||||||
|
var archSynonyms = map[string][]string{
|
||||||
|
"amd64": {"amd64", "x86_64", "x64"},
|
||||||
|
"x86_64": {"amd64", "x86_64", "x64"},
|
||||||
|
"x64": {"amd64", "x86_64", "x64"},
|
||||||
|
"386": {"386", "x86"},
|
||||||
|
"x86": {"386", "x86"},
|
||||||
|
"arm64": {"arm64", "aarch64"},
|
||||||
|
"aarch64": {"arm64", "aarch64"},
|
||||||
|
"arm": {"arm"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractArchive(archivePath, destDir string) error {
|
||||||
|
lower := strings.ToLower(archivePath)
|
||||||
|
if strings.HasSuffix(lower, ".zip") {
|
||||||
|
return extractZip(archivePath, destDir)
|
||||||
|
}
|
||||||
|
// treat .tar.gz and .tgz as gzip+tar
|
||||||
|
if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
|
||||||
|
return extractTarGz(archivePath, destDir)
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(lower, ".tar") {
|
||||||
|
return extractTar(archivePath, destDir)
|
||||||
|
}
|
||||||
|
// fallback: try tar.gz
|
||||||
|
return extractTarGz(archivePath, destDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractZip(archivePath, destDir string) error {
|
||||||
|
r, err := zip.OpenReader(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
destClean := filepath.Clean(destDir)
|
||||||
|
for _, f := range r.File {
|
||||||
|
target := filepath.Clean(filepath.Join(destClean, f.Name))
|
||||||
|
if !strings.HasPrefix(target, destClean+string(os.PathSeparator)) && target != destClean {
|
||||||
|
return fmt.Errorf("path traversal detected: %s", f.Name)
|
||||||
|
}
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
if err := os.MkdirAll(target, f.FileInfo().Mode()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, f.FileInfo().Mode())
|
||||||
|
if err != nil {
|
||||||
|
rc.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(out, rc); err != nil {
|
||||||
|
rc.Close()
|
||||||
|
out.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rc.Close()
|
||||||
|
out.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTarGz(archivePath, destDir string) error {
|
||||||
|
f, err := os.Open(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
gzr, err := gzip.NewReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer gzr.Close()
|
||||||
|
tr := tar.NewReader(gzr)
|
||||||
|
return extractTarFromReader(tr, destDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTar(archivePath, destDir string) error {
|
||||||
|
f, err := os.Open(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
tr := tar.NewReader(f)
|
||||||
|
return extractTarFromReader(tr, destDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTarFromReader contains logic common to extracting entries from a
|
||||||
|
// tar.Reader and is used by both extractTarGz and extractTar to avoid
|
||||||
|
// duplicated code (golangci-lint: dupl).
|
||||||
|
func extractTarFromReader(tr *tar.Reader, destDir string) error {
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := filepath.Clean(filepath.Join(filepath.Clean(destDir), hdr.Name))
|
||||||
|
if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) &&
|
||||||
|
target != filepath.Clean(destDir) {
|
||||||
|
return fmt.Errorf("path traversal detected: %s", hdr.Name)
|
||||||
|
}
|
||||||
|
switch hdr.Typeflag {
|
||||||
|
case tar.TypeDir:
|
||||||
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case tar.TypeReg:
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(out, tr); err != nil {
|
||||||
|
out.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findBinaryInDir(dir, programName string) (string, error) {
|
||||||
|
wanted := []string{programName}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
wanted = append([]string{programName + ".exe"}, wanted...)
|
||||||
|
} else {
|
||||||
|
// also accept programs with .exe in archives targeting windows
|
||||||
|
wanted = append(wanted, programName+".exe")
|
||||||
|
}
|
||||||
|
|
||||||
|
var found string
|
||||||
|
if err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil || found != "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
base := filepath.Base(p)
|
||||||
|
for _, w := range wanted {
|
||||||
|
if base == w {
|
||||||
|
found = p
|
||||||
|
return io.EOF // use EOF to stop walking early
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil && err != io.EOF {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if found == "" {
|
||||||
|
return "", fmt.Errorf("binary %q not found in archive", programName)
|
||||||
|
}
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUpdateCommand returns a cobra command that triggers UpdateSelfFromRelease.
|
||||||
|
func NewUpdateCommand(binaryName string) *cobra.Command {
|
||||||
|
var urlStr, platform, arch string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "update",
|
||||||
|
Short: "Check and apply updates from GitHub releases",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if platform == "" {
|
||||||
|
platform = runtime.GOOS
|
||||||
|
}
|
||||||
|
if arch == "" {
|
||||||
|
arch = runtime.GOARCH
|
||||||
|
}
|
||||||
|
fmt.Printf("Current version: %s\n", config.FormatVersion())
|
||||||
|
if err := UpdateSelfFromRelease(urlStr, platform, arch, binaryName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("Update applied; restart to use the new version.")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(&urlStr, "url", "u", "", "Direct URL to download release asset or release page")
|
||||||
|
cmd.Flags().StringVar(&platform, "platform", "", "Target platform (default: runtime.GOOS)")
|
||||||
|
cmd.Flags().StringVar(&arch, "arch", "", "Target arch (default: runtime.GOARCH)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
97
pkg/updater/updater_test.go
Normal file
97
pkg/updater/updater_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// matchesMagic checks whether the file at path looks like a platform binary
|
||||||
|
// by inspecting magic bytes (ELF for linux, MZ for windows).
|
||||||
|
func matchesMagic(path, platform string) (bool, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
buf := make([]byte, 4)
|
||||||
|
n, err := f.Read(buf)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if n >= 4 && buf[0] == 0x7f && buf[1] == 'E' && buf[2] == 'L' && buf[3] == 'F' {
|
||||||
|
return strings.Contains(platform, "linux"), nil
|
||||||
|
}
|
||||||
|
if n >= 2 && buf[0] == 'M' && buf[1] == 'Z' {
|
||||||
|
return strings.Contains(platform, "windows"), nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDownloadAndExtractRelease_RealPlatforms downloads the latest release
|
||||||
|
// asset for multiple platform/arch combos and inspects the extracted
|
||||||
|
// artifacts to ensure a binary-like file is present. This is a network test
|
||||||
|
// and is skipped in short mode.
|
||||||
|
func TestDownloadAndExtractRelease_RealPlatforms(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping network tests in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
combos := []struct{ platform, arch string }{
|
||||||
|
{"linux", "amd64"},
|
||||||
|
{"linux", "arm64"},
|
||||||
|
{"windows", "amd64"},
|
||||||
|
{"windows", "arm64"},
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := GetProdReleaseAPIURL()
|
||||||
|
for _, c := range combos {
|
||||||
|
t.Run(c.platform+"_"+c.arch, func(t *testing.T) {
|
||||||
|
assetURL, checksum, err := findAssetInfo(apiURL, c.platform, c.arch)
|
||||||
|
if err != nil {
|
||||||
|
// If no checksum could be located for this asset, skip this
|
||||||
|
// combo rather than failing — we require signed/checksummed
|
||||||
|
// releases for real-network tests.
|
||||||
|
t.Skipf("skipping %s/%s: %v", c.platform, c.arch, err)
|
||||||
|
}
|
||||||
|
t.Logf("asset URL: %s checksum: %s", assetURL, checksum)
|
||||||
|
|
||||||
|
// Pass the release API URL (not the direct asset URL) so
|
||||||
|
// DownloadAndExtractRelease can locate and verify the asset.
|
||||||
|
dir, err := DownloadAndExtractRelease(apiURL, c.platform, c.arch)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DownloadAndExtractRelease failed for %s/%s: %v", c.platform, c.arch, err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
|
var found bool
|
||||||
|
_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.Size() < 64 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ok, err := matchesMagic(path, c.platform)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
found = true
|
||||||
|
t.Logf("found artifact: %s (size=%d)", path, info.Size())
|
||||||
|
// continue walking to list all
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("no binary-like artifact found for %s/%s", c.platform, c.arch)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@ if [ -z "$EXECUTABLE" ]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
LAUNCHER_EXECUTABLE="picoclaw-launcher-${EXECUTABLE}"
|
||||||
|
EXECUTABLE="picoclaw-${EXECUTABLE}"
|
||||||
echo "executable: $EXECUTABLE"
|
echo "executable: $EXECUTABLE"
|
||||||
|
|
||||||
APP_NAME="PicoClaw Launcher"
|
APP_NAME="PicoClaw Launcher"
|
||||||
|
|
@ -33,17 +35,17 @@ mkdir -p "$APP_RESOURCES"
|
||||||
|
|
||||||
# Copy executable
|
# Copy executable
|
||||||
echo "Copying executable..."
|
echo "Copying executable..."
|
||||||
if [ -f "./web/build/${APP_EXECUTABLE}" ]; then
|
if [ -f "./build/${LAUNCHER_EXECUTABLE}" ]; then
|
||||||
cp "./web/build/${APP_EXECUTABLE}" "${APP_MACOS}/"
|
cp "./build/${LAUNCHER_EXECUTABLE}" "${APP_MACOS}/${APP_EXECUTABLE}"
|
||||||
else
|
else
|
||||||
echo "Error: ./web/build/${APP_EXECUTABLE} not found. Please build the web backend first."
|
echo "Error: ./build/${LAUNCHER_EXECUTABLE} not found. Please build the web backend first."
|
||||||
echo "Run: make build in web dir"
|
echo "Run: make build-launcher"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ -f "./build/picoclaw" ]; then
|
if [ -f "./build/${EXECUTABLE}" ]; then
|
||||||
cp "./build/picoclaw" "${APP_MACOS}/"
|
cp "./build/${EXECUTABLE}" "${APP_MACOS}/picoclaw"
|
||||||
else
|
else
|
||||||
echo "Error: ./build/picoclaw not found. Please build the main file first."
|
echo "Error: ./build/${EXECUTABLE} not found. Please build the main file first."
|
||||||
echo "Run: make build"
|
echo "Run: make build"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
@ -76,10 +78,10 @@ cat > "${APP_CONTENTS}/Info.plist" << 'EOF'
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>LSRequiresCarbon</key>
|
|
||||||
<true/>
|
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
<string>1</string>
|
<true/>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>10.11</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
EOF
|
EOF
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package api
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type channelCatalogItem struct {
|
type channelCatalogItem struct {
|
||||||
|
|
@ -30,9 +32,22 @@ var channelCatalog = []channelCatalogItem{
|
||||||
{Name: "irc", ConfigKey: "irc"},
|
{Name: "irc", ConfigKey: "irc"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type channelConfigResponse struct {
|
||||||
|
Config any `json:"config"`
|
||||||
|
ConfiguredSecrets []string `json:"configured_secrets"`
|
||||||
|
ConfigKey string `json:"config_key"`
|
||||||
|
Variant string `json:"variant,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelSecretPresence struct {
|
||||||
|
key string
|
||||||
|
configured bool
|
||||||
|
}
|
||||||
|
|
||||||
// registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux.
|
// registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux.
|
||||||
func (h *Handler) registerChannelRoutes(mux *http.ServeMux) {
|
func (h *Handler) registerChannelRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog)
|
mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog)
|
||||||
|
mux.HandleFunc("GET /api/channels/{name}/config", h.handleGetChannelConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleListChannelCatalog returns the channels supported by backend.
|
// handleListChannelCatalog returns the channels supported by backend.
|
||||||
|
|
@ -44,3 +59,172 @@ func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Reques
|
||||||
"channels": channelCatalog,
|
"channels": channelCatalog,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetChannelConfig returns safe channel config plus secret presence metadata.
|
||||||
|
//
|
||||||
|
// GET /api/channels/{name}/config
|
||||||
|
func (h *Handler) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
channelName := r.PathValue("name")
|
||||||
|
item, ok := findChannelCatalogItem(channelName)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Channel not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to load config", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildChannelConfigResponse(cfg, item)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||||
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findChannelCatalogItem(name string) (channelCatalogItem, bool) {
|
||||||
|
for _, item := range channelCatalog {
|
||||||
|
if item.Name == name {
|
||||||
|
return item, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return channelCatalogItem{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) channelConfigResponse {
|
||||||
|
resp := channelConfigResponse{
|
||||||
|
ConfiguredSecrets: []string{},
|
||||||
|
ConfigKey: item.ConfigKey,
|
||||||
|
Variant: item.Variant,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch item.Name {
|
||||||
|
case "weixin":
|
||||||
|
channelCfg := cfg.Channels.Weixin
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.Token = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "telegram":
|
||||||
|
channelCfg := cfg.Channels.Telegram
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.Token = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "discord":
|
||||||
|
channelCfg := cfg.Channels.Discord
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.Token = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "slack":
|
||||||
|
channelCfg := cfg.Channels.Slack
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "bot_token", configured: channelCfg.BotToken.String() != ""},
|
||||||
|
channelSecretPresence{key: "app_token", configured: channelCfg.AppToken.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.BotToken = config.SecureString{}
|
||||||
|
channelCfg.AppToken = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "feishu":
|
||||||
|
channelCfg := cfg.Channels.Feishu
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "app_secret", configured: channelCfg.AppSecret.String() != ""},
|
||||||
|
channelSecretPresence{key: "encrypt_key", configured: channelCfg.EncryptKey.String() != ""},
|
||||||
|
channelSecretPresence{key: "verification_token", configured: channelCfg.VerificationToken.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.AppSecret = config.SecureString{}
|
||||||
|
channelCfg.EncryptKey = config.SecureString{}
|
||||||
|
channelCfg.VerificationToken = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "dingtalk":
|
||||||
|
channelCfg := cfg.Channels.DingTalk
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "client_secret", configured: channelCfg.ClientSecret.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.ClientSecret = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "line":
|
||||||
|
channelCfg := cfg.Channels.LINE
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "channel_secret", configured: channelCfg.ChannelSecret.String() != ""},
|
||||||
|
channelSecretPresence{
|
||||||
|
key: "channel_access_token",
|
||||||
|
configured: channelCfg.ChannelAccessToken.String() != "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
channelCfg.ChannelSecret = config.SecureString{}
|
||||||
|
channelCfg.ChannelAccessToken = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "qq":
|
||||||
|
channelCfg := cfg.Channels.QQ
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "app_secret", configured: channelCfg.AppSecret.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.AppSecret = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "onebot":
|
||||||
|
channelCfg := cfg.Channels.OneBot
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "access_token", configured: channelCfg.AccessToken.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.AccessToken = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "wecom":
|
||||||
|
channelCfg := cfg.Channels.WeCom
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "secret", configured: channelCfg.Secret.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.Secret = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "whatsapp", "whatsapp_native":
|
||||||
|
resp.Config = cfg.Channels.WhatsApp
|
||||||
|
case "pico":
|
||||||
|
channelCfg := cfg.Channels.Pico
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.Token = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "maixcam":
|
||||||
|
resp.Config = cfg.Channels.MaixCam
|
||||||
|
case "matrix":
|
||||||
|
channelCfg := cfg.Channels.Matrix
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "access_token", configured: channelCfg.AccessToken.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.AccessToken = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "irc":
|
||||||
|
channelCfg := cfg.Channels.IRC
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "password", configured: channelCfg.Password.String() != ""},
|
||||||
|
channelSecretPresence{key: "nickserv_password", configured: channelCfg.NickServPassword.String() != ""},
|
||||||
|
channelSecretPresence{key: "sasl_password", configured: channelCfg.SASLPassword.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.Password = config.SecureString{}
|
||||||
|
channelCfg.NickServPassword = config.SecureString{}
|
||||||
|
channelCfg.SASLPassword = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
default:
|
||||||
|
resp.Config = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectConfiguredSecrets(secrets ...channelSecretPresence) []string {
|
||||||
|
configured := make([]string, 0, len(secrets))
|
||||||
|
for _, secret := range secrets {
|
||||||
|
if secret.configured {
|
||||||
|
configured = append(configured, secret.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
|
|
||||||
87
web/backend/api/channels_test.go
Normal file
87
web/backend/api/channels_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleGetChannelConfig_ReturnsSecretPresenceWithoutLeakingSecrets(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.Channels.Feishu.Enabled = true
|
||||||
|
cfg.Channels.Feishu.AppID = "cli_test_app"
|
||||||
|
cfg.Channels.Feishu.AppSecret = *config.NewSecureString("feishu-secret-from-security")
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/channels/feishu/config", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf(
|
||||||
|
"GET /api/channels/feishu/config status = %d, want %d, body=%s",
|
||||||
|
rec.Code,
|
||||||
|
http.StatusOK,
|
||||||
|
rec.Body.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "feishu-secret-from-security") {
|
||||||
|
t.Fatalf("response leaked secret value: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Config map[string]any `json:"config"`
|
||||||
|
ConfiguredSecrets []string `json:"configured_secrets"`
|
||||||
|
ConfigKey string `json:"config_key"`
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := resp.ConfigKey; got != "feishu" {
|
||||||
|
t.Fatalf("config_key = %q, want %q", got, "feishu")
|
||||||
|
}
|
||||||
|
if got := resp.Config["app_id"]; got != "cli_test_app" {
|
||||||
|
t.Fatalf("config.app_id = %#v, want %q", got, "cli_test_app")
|
||||||
|
}
|
||||||
|
if _, exists := resp.Config["app_secret"]; exists {
|
||||||
|
t.Fatalf("config should omit app_secret, got %#v", resp.Config["app_secret"])
|
||||||
|
}
|
||||||
|
if len(resp.ConfiguredSecrets) != 1 || resp.ConfiguredSecrets[0] != "app_secret" {
|
||||||
|
t.Fatalf("configured_secrets = %#v, want [\"app_secret\"]", resp.ConfiguredSecrets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetChannelConfig_ReturnsNotFoundForUnknownChannel(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/channels/not-a-channel/config", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("GET /api/channels/not-a-channel/config status = %d, want %d", rec.Code, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -81,6 +81,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
// Launcher service parameters (port/public)
|
// Launcher service parameters (port/public)
|
||||||
h.registerLauncherConfigRoutes(mux)
|
h.registerLauncherConfigRoutes(mux)
|
||||||
|
|
||||||
|
// Self-update endpoint (requires dashboard auth)
|
||||||
|
h.registerUpdateRoutes(mux)
|
||||||
|
|
||||||
// Runtime build/version metadata
|
// Runtime build/version metadata
|
||||||
h.registerVersionRoutes(mux)
|
h.registerVersionRoutes(mux)
|
||||||
|
|
||||||
|
|
|
||||||
52
web/backend/api/update.go
Normal file
52
web/backend/api/update.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerUpdateRoutes registers the self-update endpoint.
|
||||||
|
func (h *Handler) registerUpdateRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/update", h.handleUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateRequest struct {
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Binary string `json:"binary,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
_ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: "method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||||
|
var req updateRequest
|
||||||
|
if err := dec.Decode(&req); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binary := req.Binary
|
||||||
|
if binary == "" {
|
||||||
|
binary = "picoclaw-launcher"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := updater.UpdateSelfFromRelease(req.URL, "", "", binary); err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(updateResponse{Status: "ok", Message: "update applied; restart to use new version"})
|
||||||
|
}
|
||||||
|
|
@ -71,6 +71,7 @@ func Recoverer(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := recover(); err != nil {
|
if err := recover(); err != nil {
|
||||||
|
logger.RecoverPanicNoExit(err)
|
||||||
logger.ErrorC("http", fmt.Sprintf("panic recovered: %v\n%s", err, debug.Stack()))
|
logger.ErrorC("http", fmt.Sprintf("panic recovered: %v\n%s", err, debug.Stack()))
|
||||||
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
// API client for channels navigation and channel-specific config flows.
|
|
||||||
|
|
||||||
import { launcherFetch } from "@/api/http"
|
import { launcherFetch } from "@/api/http"
|
||||||
|
|
||||||
export type ChannelConfig = Record<string, unknown>
|
export type ChannelConfig = Record<string, unknown>
|
||||||
|
|
@ -12,6 +10,13 @@ export interface SupportedChannel {
|
||||||
variant?: string
|
variant?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChannelConfigResponse {
|
||||||
|
config: ChannelConfig
|
||||||
|
configured_secrets: string[]
|
||||||
|
config_key: string
|
||||||
|
variant?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface ChannelsCatalogResponse {
|
interface ChannelsCatalogResponse {
|
||||||
channels: SupportedChannel[]
|
channels: SupportedChannel[]
|
||||||
}
|
}
|
||||||
|
|
@ -54,6 +59,14 @@ export async function getAppConfig(): Promise<AppConfig> {
|
||||||
return request<AppConfig>("/api/config")
|
return request<AppConfig>("/api/config")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getChannelConfig(
|
||||||
|
channelName: string,
|
||||||
|
): Promise<ChannelConfigResponse> {
|
||||||
|
return request<ChannelConfigResponse>(
|
||||||
|
`/api/channels/${encodeURIComponent(channelName)}/config`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export async function patchAppConfig(
|
export async function patchAppConfig(
|
||||||
patch: Record<string, unknown>,
|
patch: Record<string, unknown>,
|
||||||
): Promise<ConfigActionResponse> {
|
): Promise<ConfigActionResponse> {
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,10 @@ import {
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconTools,
|
IconTools,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Link, useRouterState } from "@tanstack/react-router"
|
import { Link, useRouterState } from "@tanstack/react-router"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import { getSystemVersionInfo } from "@/api/system"
|
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
|
|
@ -25,7 +23,6 @@ import {
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarFooter,
|
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
|
|
@ -84,13 +81,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
language: (i18n.resolvedLanguage ?? i18n.language ?? "").toLowerCase(),
|
language: (i18n.resolvedLanguage ?? i18n.language ?? "").toLowerCase(),
|
||||||
t,
|
t,
|
||||||
})
|
})
|
||||||
const { data: versionInfo } = useQuery({
|
|
||||||
queryKey: ["system", "version"],
|
|
||||||
queryFn: getSystemVersionInfo,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const versionText = versionInfo?.version ?? t("footer.version_unknown")
|
|
||||||
const handleNavItemClick = React.useCallback(() => {
|
const handleNavItemClick = React.useCallback(() => {
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setOpenMobile(false)
|
setOpenMobile(false)
|
||||||
|
|
@ -263,26 +254,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
))}
|
))}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter className="border-t-border/30 border-t px-3 py-2 group-data-[collapsible=icon]:hidden">
|
|
||||||
<div className="text-muted-foreground flex flex-col gap-0.5 text-[11px] leading-4">
|
|
||||||
<div className="truncate" title={versionText}>
|
|
||||||
<span className="text-foreground/80">{t("footer.version")}:</span>{" "}
|
|
||||||
{versionText}
|
|
||||||
</div>
|
|
||||||
{versionInfo?.git_commit && (
|
|
||||||
<div className="truncate" title={versionInfo.git_commit}>
|
|
||||||
<span className="text-foreground/80">{t("footer.commit")}:</span>{" "}
|
|
||||||
{versionInfo.git_commit}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{versionInfo?.build_time && (
|
|
||||||
<div className="truncate" title={versionInfo.build_time}>
|
|
||||||
<span className="text-foreground/80">{t("footer.build")}:</span>{" "}
|
|
||||||
{versionInfo.build_time}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SidebarFooter>
|
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
101
web/frontend/src/components/channels/channel-config-fields.ts
Normal file
101
web/frontend/src/components/channels/channel-config-fields.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
|
|
||||||
|
export const SECRET_FIELD_MAP = {
|
||||||
|
token: "_token",
|
||||||
|
app_secret: "_app_secret",
|
||||||
|
client_secret: "_client_secret",
|
||||||
|
corp_secret: "_corp_secret",
|
||||||
|
channel_secret: "_channel_secret",
|
||||||
|
channel_access_token: "_channel_access_token",
|
||||||
|
access_token: "_access_token",
|
||||||
|
bot_token: "_bot_token",
|
||||||
|
app_token: "_app_token",
|
||||||
|
encoding_aes_key: "_encoding_aes_key",
|
||||||
|
encrypt_key: "_encrypt_key",
|
||||||
|
verification_token: "_verification_token",
|
||||||
|
secret: "_secret",
|
||||||
|
password: "_password",
|
||||||
|
nickserv_password: "_nickserv_password",
|
||||||
|
sasl_password: "_sasl_password",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const CHANNEL_SECRET_FIELDS: Record<string, string[]> = {
|
||||||
|
weixin: ["token"],
|
||||||
|
telegram: ["token"],
|
||||||
|
discord: ["token"],
|
||||||
|
slack: ["bot_token", "app_token"],
|
||||||
|
feishu: ["app_secret", "encrypt_key", "verification_token"],
|
||||||
|
dingtalk: ["client_secret"],
|
||||||
|
line: ["channel_secret", "channel_access_token"],
|
||||||
|
qq: ["app_secret"],
|
||||||
|
onebot: ["access_token"],
|
||||||
|
wecom: ["secret"],
|
||||||
|
pico: ["token"],
|
||||||
|
matrix: ["access_token"],
|
||||||
|
irc: ["password", "nickserv_password", "sasl_password"],
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECRET_FIELD_SET = new Set(Object.keys(SECRET_FIELD_MAP))
|
||||||
|
|
||||||
|
function asString(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSecretField(key: string): boolean {
|
||||||
|
return SECRET_FIELD_SET.has(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEditConfig(
|
||||||
|
channelName: string,
|
||||||
|
config: ChannelConfig,
|
||||||
|
): ChannelConfig {
|
||||||
|
const edit: ChannelConfig = { ...config }
|
||||||
|
|
||||||
|
for (const key of CHANNEL_SECRET_FIELDS[channelName] ?? []) {
|
||||||
|
if (!(key in edit)) {
|
||||||
|
edit[key] = ""
|
||||||
|
}
|
||||||
|
const editKey = SECRET_FIELD_MAP[key as keyof typeof SECRET_FIELD_MAP]
|
||||||
|
if (editKey) {
|
||||||
|
edit[editKey] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return edit
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasConfiguredSecret(
|
||||||
|
configuredSecrets: readonly string[],
|
||||||
|
key: string,
|
||||||
|
): boolean {
|
||||||
|
return configuredSecrets.includes(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFieldValueForValidation(
|
||||||
|
config: ChannelConfig,
|
||||||
|
configuredSecrets: readonly string[],
|
||||||
|
key: string,
|
||||||
|
): unknown {
|
||||||
|
const editKey = SECRET_FIELD_MAP[key as keyof typeof SECRET_FIELD_MAP]
|
||||||
|
if (editKey) {
|
||||||
|
const incoming = asString(config[editKey]).trim()
|
||||||
|
if (incoming !== "") {
|
||||||
|
return incoming
|
||||||
|
}
|
||||||
|
if (hasConfiguredSecret(configuredSecrets, key)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSecretInputPlaceholder(
|
||||||
|
configuredSecrets: readonly string[],
|
||||||
|
key: string,
|
||||||
|
configuredPlaceholder: string,
|
||||||
|
fallback = "",
|
||||||
|
): string {
|
||||||
|
return hasConfiguredSecret(configuredSecrets, key)
|
||||||
|
? configuredPlaceholder
|
||||||
|
: fallback
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
import { IconAlertTriangle, IconLoader2 } from "@tabler/icons-react"
|
import { IconLoader2 } from "@tabler/icons-react"
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ChannelConfig,
|
type ChannelConfig,
|
||||||
type SupportedChannel,
|
type SupportedChannel,
|
||||||
getAppConfig,
|
getChannelConfig,
|
||||||
getChannelsCatalog,
|
getChannelsCatalog,
|
||||||
patchAppConfig,
|
patchAppConfig,
|
||||||
} from "@/api/channels"
|
} from "@/api/channels"
|
||||||
|
import {
|
||||||
|
SECRET_FIELD_MAP,
|
||||||
|
buildEditConfig,
|
||||||
|
getFieldValueForValidation,
|
||||||
|
isSecretField,
|
||||||
|
} from "@/components/channels/channel-config-fields"
|
||||||
import { getChannelDisplayName } from "@/components/channels/channel-display-name"
|
import { getChannelDisplayName } from "@/components/channels/channel-display-name"
|
||||||
import { DiscordForm } from "@/components/channels/channel-forms/discord-form"
|
import { DiscordForm } from "@/components/channels/channel-forms/discord-form"
|
||||||
import { FeishuForm } from "@/components/channels/channel-forms/feishu-form"
|
import { FeishuForm } from "@/components/channels/channel-forms/feishu-form"
|
||||||
|
|
@ -27,24 +33,6 @@ interface ChannelConfigPageProps {
|
||||||
channelName: string
|
channelName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECRET_FIELD_MAP: Record<string, string> = {
|
|
||||||
token: "_token",
|
|
||||||
app_secret: "_app_secret",
|
|
||||||
client_secret: "_client_secret",
|
|
||||||
corp_secret: "_corp_secret",
|
|
||||||
channel_secret: "_channel_secret",
|
|
||||||
channel_access_token: "_channel_access_token",
|
|
||||||
access_token: "_access_token",
|
|
||||||
bot_token: "_bot_token",
|
|
||||||
app_token: "_app_token",
|
|
||||||
encoding_aes_key: "_encoding_aes_key",
|
|
||||||
encrypt_key: "_encrypt_key",
|
|
||||||
verification_token: "_verification_token",
|
|
||||||
password: "_password",
|
|
||||||
nickserv_password: "_nickserv_password",
|
|
||||||
sasl_password: "_sasl_password",
|
|
||||||
}
|
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> {
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
return value as Record<string, unknown>
|
return value as Record<string, unknown>
|
||||||
|
|
@ -60,14 +48,6 @@ function asBool(value: unknown): boolean {
|
||||||
return value === true
|
return value === true
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEditConfig(config: ChannelConfig): ChannelConfig {
|
|
||||||
const edit: ChannelConfig = { ...config }
|
|
||||||
for (const editKey of Object.values(SECRET_FIELD_MAP)) {
|
|
||||||
edit[editKey] = ""
|
|
||||||
}
|
|
||||||
return edit
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeConfig(
|
function normalizeConfig(
|
||||||
channel: SupportedChannel,
|
channel: SupportedChannel,
|
||||||
rawConfig: ChannelConfig,
|
rawConfig: ChannelConfig,
|
||||||
|
|
@ -92,7 +72,7 @@ function buildSavePayload(
|
||||||
for (const [key, value] of Object.entries(editConfig)) {
|
for (const [key, value] of Object.entries(editConfig)) {
|
||||||
if (key.startsWith("_")) continue
|
if (key.startsWith("_")) continue
|
||||||
if (key === "enabled") continue
|
if (key === "enabled") continue
|
||||||
if (key in SECRET_FIELD_MAP) continue
|
if (isSecretField(key)) continue
|
||||||
|
|
||||||
payload[key] = value
|
payload[key] = value
|
||||||
}
|
}
|
||||||
|
|
@ -103,8 +83,9 @@ function buildSavePayload(
|
||||||
payload[secretKey] = incoming
|
payload[secretKey] = incoming
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (secretKey in editConfig) {
|
const existing = asString(editConfig[secretKey]).trim()
|
||||||
payload[secretKey] = editConfig[secretKey]
|
if (existing !== "") {
|
||||||
|
payload[secretKey] = existing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,51 +102,50 @@ function buildSavePayload(
|
||||||
function isConfigured(
|
function isConfigured(
|
||||||
channel: SupportedChannel,
|
channel: SupportedChannel,
|
||||||
config: ChannelConfig,
|
config: ChannelConfig,
|
||||||
|
configuredSecrets: readonly string[],
|
||||||
): boolean {
|
): boolean {
|
||||||
|
const hasValue = (key: string) =>
|
||||||
|
!isMissingRequiredValue(
|
||||||
|
getFieldValueForValidation(config, configuredSecrets, key),
|
||||||
|
)
|
||||||
|
|
||||||
switch (channel.name) {
|
switch (channel.name) {
|
||||||
case "telegram":
|
case "telegram":
|
||||||
return asString(config.token) !== ""
|
return hasValue("token")
|
||||||
case "discord":
|
case "discord":
|
||||||
return asString(config.token) !== ""
|
return hasValue("token")
|
||||||
case "slack":
|
case "slack":
|
||||||
return asString(config.bot_token) !== ""
|
return hasValue("bot_token")
|
||||||
case "feishu":
|
case "feishu":
|
||||||
return (
|
return hasValue("app_id") && hasValue("app_secret")
|
||||||
asString(config.app_id) !== "" && asString(config.app_secret) !== ""
|
|
||||||
)
|
|
||||||
case "dingtalk":
|
case "dingtalk":
|
||||||
return (
|
return hasValue("client_id") && hasValue("client_secret")
|
||||||
asString(config.client_id) !== "" &&
|
|
||||||
asString(config.client_secret) !== ""
|
|
||||||
)
|
|
||||||
case "line":
|
case "line":
|
||||||
return asString(config.channel_access_token) !== ""
|
return hasValue("channel_secret") && hasValue("channel_access_token")
|
||||||
case "qq":
|
case "qq":
|
||||||
return (
|
return hasValue("app_id") && hasValue("app_secret")
|
||||||
asString(config.app_id) !== "" && asString(config.app_secret) !== ""
|
|
||||||
)
|
|
||||||
case "onebot":
|
case "onebot":
|
||||||
return asString(config.ws_url) !== ""
|
return hasValue("ws_url")
|
||||||
case "weixin":
|
case "weixin":
|
||||||
return asString(config.account_id) !== ""
|
return hasValue("account_id")
|
||||||
case "wecom":
|
case "wecom":
|
||||||
return asString(config.bot_id) !== ""
|
return hasValue("bot_id")
|
||||||
case "whatsapp":
|
case "whatsapp":
|
||||||
return asString(config.bridge_url) !== ""
|
return hasValue("bridge_url")
|
||||||
case "whatsapp_native":
|
case "whatsapp_native":
|
||||||
return asBool(config.use_native)
|
return asBool(config.use_native)
|
||||||
case "pico":
|
case "pico":
|
||||||
return asString(config.token) !== ""
|
return hasValue("token")
|
||||||
case "maixcam":
|
case "maixcam":
|
||||||
return asString(config.host) !== ""
|
return hasValue("host")
|
||||||
case "matrix":
|
case "matrix":
|
||||||
return (
|
return (
|
||||||
asString(config.homeserver) !== "" &&
|
hasValue("homeserver") &&
|
||||||
asString(config.user_id) !== "" &&
|
hasValue("user_id") &&
|
||||||
asString(config.access_token) !== ""
|
hasValue("access_token")
|
||||||
)
|
)
|
||||||
case "irc":
|
case "irc":
|
||||||
return asString(config.server) !== ""
|
return hasValue("server")
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -245,21 +225,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
const [channel, setChannel] = useState<SupportedChannel | null>(null)
|
const [channel, setChannel] = useState<SupportedChannel | null>(null)
|
||||||
const [baseConfig, setBaseConfig] = useState<ChannelConfig>({})
|
const [baseConfig, setBaseConfig] = useState<ChannelConfig>({})
|
||||||
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
||||||
|
const [configuredSecrets, setConfiguredSecrets] = useState<string[]>([])
|
||||||
const [enabled, setEnabled] = useState(false)
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
|
||||||
const loadData = useCallback(
|
const loadData = useCallback(
|
||||||
async (silent = false) => {
|
async (silent = false) => {
|
||||||
if (!silent) setLoading(true)
|
if (!silent) setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [catalog, appConfig] = await Promise.all([
|
const catalog = await getChannelsCatalog()
|
||||||
getChannelsCatalog(),
|
|
||||||
getAppConfig(),
|
|
||||||
])
|
|
||||||
const matched =
|
const matched =
|
||||||
catalog.channels.find((item) => item.name === channelName) ?? null
|
catalog.channels.find((item) => item.name === channelName) ?? null
|
||||||
|
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
setChannel(null)
|
setChannel(null)
|
||||||
|
setBaseConfig({})
|
||||||
|
setEditConfig({})
|
||||||
|
setConfiguredSecrets([])
|
||||||
|
setEnabled(false)
|
||||||
setFetchError(
|
setFetchError(
|
||||||
t("channels.page.notFound", {
|
t("channels.page.notFound", {
|
||||||
name: channelName,
|
name: channelName,
|
||||||
|
|
@ -268,18 +250,20 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const channelsConfig = asRecord(asRecord(appConfig).channels)
|
const channelConfig = await getChannelConfig(channelName)
|
||||||
const raw = asRecord(channelsConfig[matched.config_key])
|
const raw = asRecord(channelConfig.config)
|
||||||
const normalized = normalizeConfig(matched, raw)
|
const normalized = normalizeConfig(matched, raw)
|
||||||
|
|
||||||
setChannel(matched)
|
setChannel(matched)
|
||||||
setBaseConfig(normalized)
|
setBaseConfig(normalized)
|
||||||
setEditConfig(buildEditConfig(normalized))
|
setEditConfig(buildEditConfig(matched.name, normalized))
|
||||||
|
setConfiguredSecrets(channelConfig.configured_secrets ?? [])
|
||||||
setEnabled(asBool(normalized.enabled))
|
setEnabled(asBool(normalized.enabled))
|
||||||
setFetchError("")
|
setFetchError("")
|
||||||
setServerError("")
|
setServerError("")
|
||||||
setFieldErrors({})
|
setFieldErrors({})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setConfiguredSecrets([])
|
||||||
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
|
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) setLoading(false)
|
if (!silent) setLoading(false)
|
||||||
|
|
@ -307,9 +291,9 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
}, [channel, editConfig, enabled])
|
}, [channel, editConfig, enabled])
|
||||||
|
|
||||||
const configured = useMemo(() => {
|
const configured = useMemo(() => {
|
||||||
if (!channel || !savePayload) return false
|
if (!channel) return false
|
||||||
return isConfigured(channel, savePayload)
|
return isConfigured(channel, editConfig, configuredSecrets)
|
||||||
}, [channel, savePayload])
|
}, [channel, configuredSecrets, editConfig])
|
||||||
|
|
||||||
const docsUrl = useMemo(() => {
|
const docsUrl = useMemo(() => {
|
||||||
if (!channel) return ""
|
if (!channel) return ""
|
||||||
|
|
@ -362,7 +346,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setEditConfig(buildEditConfig(baseConfig))
|
if (!channel) return
|
||||||
|
setEditConfig(buildEditConfig(channel.name, baseConfig))
|
||||||
setEnabled(asBool(baseConfig.enabled))
|
setEnabled(asBool(baseConfig.enabled))
|
||||||
setServerError("")
|
setServerError("")
|
||||||
setFieldErrors({})
|
setFieldErrors({})
|
||||||
|
|
@ -372,7 +357,9 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
if (!channel || !savePayload) return
|
if (!channel || !savePayload) return
|
||||||
|
|
||||||
const missingRequiredFields = requiredKeys.filter((key) =>
|
const missingRequiredFields = requiredKeys.filter((key) =>
|
||||||
isMissingRequiredValue(savePayload[key]),
|
isMissingRequiredValue(
|
||||||
|
getFieldValueForValidation(editConfig, configuredSecrets, key),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if (missingRequiredFields.length > 0) {
|
if (missingRequiredFields.length > 0) {
|
||||||
const requiredFieldError = t("channels.validation.requiredField")
|
const requiredFieldError = t("channels.validation.requiredField")
|
||||||
|
|
@ -456,7 +443,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
<TelegramForm
|
<TelegramForm
|
||||||
config={editConfig}
|
config={editConfig}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isEdit={isEdit}
|
configuredSecrets={configuredSecrets}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -465,7 +452,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
<DiscordForm
|
<DiscordForm
|
||||||
config={editConfig}
|
config={editConfig}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isEdit={isEdit}
|
configuredSecrets={configuredSecrets}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -474,7 +461,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
<SlackForm
|
<SlackForm
|
||||||
config={editConfig}
|
config={editConfig}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isEdit={isEdit}
|
configuredSecrets={configuredSecrets}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -483,7 +470,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
<FeishuForm
|
<FeishuForm
|
||||||
config={editConfig}
|
config={editConfig}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isEdit={isEdit}
|
configuredSecrets={configuredSecrets}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -510,7 +497,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
<GenericForm
|
<GenericForm
|
||||||
config={editConfig}
|
config={editConfig}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isEdit={isEdit}
|
configuredSecrets={configuredSecrets}
|
||||||
hiddenKeys={[...hiddenKeys, "bot_id"]}
|
hiddenKeys={[...hiddenKeys, "bot_id"]}
|
||||||
requiredKeys={requiredKeys}
|
requiredKeys={requiredKeys}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
|
|
@ -522,7 +509,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
<GenericForm
|
<GenericForm
|
||||||
config={editConfig}
|
config={editConfig}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isEdit={isEdit}
|
configuredSecrets={configuredSecrets}
|
||||||
hiddenKeys={hiddenKeys}
|
hiddenKeys={hiddenKeys}
|
||||||
requiredKeys={requiredKeys}
|
requiredKeys={requiredKeys}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
|
|
@ -536,19 +523,17 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={channelDisplayName}
|
title={channelDisplayName}
|
||||||
titleExtra={
|
titleExtra={
|
||||||
channel ? (
|
channel &&
|
||||||
<div className="flex items-center gap-1.5">
|
docsUrl && (
|
||||||
{enabled ? (
|
<a
|
||||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
|
href={docsUrl}
|
||||||
{t("channels.page.enabled")}
|
target="_blank"
|
||||||
</span>
|
rel="noreferrer"
|
||||||
) : configured ? (
|
className="text-muted-foreground hover:text-foreground text-xs underline underline-offset-2"
|
||||||
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
|
>
|
||||||
{t("channels.status.configured")}
|
{t("channels.page.docLink")}
|
||||||
</span>
|
</a>
|
||||||
) : null}
|
)
|
||||||
</div>
|
|
||||||
) : undefined
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -562,46 +547,9 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
{fetchError}
|
{fetchError}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full max-w-250 space-y-5 pt-2">
|
<div className="w-full max-w-4xl space-y-6 pt-5">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<p className="font-medium">
|
|
||||||
{t("channels.edit", {
|
|
||||||
name: channelDisplayName,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
{channel && docsUrl && (
|
|
||||||
<a
|
|
||||||
href={docsUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground text-xs underline underline-offset-2"
|
|
||||||
>
|
|
||||||
{t("channels.page.docLink")}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{channel?.name === "weixin" && (
|
|
||||||
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<IconAlertTriangle
|
|
||||||
size={18}
|
|
||||||
className="mt-0.5 shrink-0 text-amber-600 dark:text-amber-400"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
|
||||||
{t("channels.weixin.warningTitle")}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-amber-700/90 dark:text-amber-300/90">
|
|
||||||
{t("channels.weixin.warningDesc")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hidesPageLevelEnableToggle && (
|
{!hidesPageLevelEnableToggle && (
|
||||||
<div className="border-border/60 bg-background flex items-center justify-between rounded-lg border px-4 py-3">
|
<div className="bg-card text-card-foreground border-border/60 flex items-center justify-between rounded-xl border px-6 py-4 shadow-sm">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{t("channels.page.enableLabel")}
|
{t("channels.page.enableLabel")}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
||||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
interface DiscordFormProps {
|
interface DiscordFormProps {
|
||||||
config: ChannelConfig
|
config: ChannelConfig
|
||||||
onChange: (key: string, value: unknown) => void
|
onChange: (key: string, value: unknown) => void
|
||||||
isEdit: boolean
|
configuredSecrets: string[]
|
||||||
fieldErrors?: Record<string, string>
|
fieldErrors?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,34 +36,38 @@ function asRecord(value: unknown): Record<string, unknown> {
|
||||||
export function DiscordForm({
|
export function DiscordForm({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
isEdit,
|
configuredSecrets,
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
}: DiscordFormProps) {
|
}: DiscordFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const groupTriggerConfig = asRecord(config.group_trigger)
|
const groupTriggerConfig = asRecord(config.group_trigger)
|
||||||
const tokenExtraHint =
|
|
||||||
isEdit && asString(config.token)
|
|
||||||
? ` ${t("channels.field.secretHintSet")}`
|
|
||||||
: ""
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.token")}
|
label={t("channels.field.token")}
|
||||||
required
|
required
|
||||||
hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
|
hint={t("channels.form.desc.token")}
|
||||||
error={fieldErrors.token}
|
error={fieldErrors.token}
|
||||||
>
|
>
|
||||||
<KeyInput
|
<KeyInput
|
||||||
value={asString(config._token)}
|
value={asString(config._token)}
|
||||||
onChange={(v) => onChange("_token", v)}
|
onChange={(v) => onChange("_token", v)}
|
||||||
placeholder={maskedSecretPlaceholder(
|
placeholder={getSecretInputPlaceholder(
|
||||||
config.token,
|
configuredSecrets,
|
||||||
|
"token",
|
||||||
|
t("channels.field.secretHintSet"),
|
||||||
t("channels.field.tokenPlaceholder"),
|
t("channels.field.tokenPlaceholder"),
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.proxy")}
|
label={t("channels.field.proxy")}
|
||||||
hint={t("channels.form.desc.proxy")}
|
hint={t("channels.form.desc.proxy")}
|
||||||
|
|
@ -92,6 +97,7 @@ export function DiscordForm({
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<div>
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("channels.field.mentionOnly")}
|
label={t("channels.field.mentionOnly")}
|
||||||
hint={t("channels.form.desc.mentionOnly")}
|
hint={t("channels.form.desc.mentionOnly")}
|
||||||
|
|
@ -105,5 +111,8 @@ export function DiscordForm({
|
||||||
ariaLabel={t("channels.field.mentionOnly")}
|
ariaLabel={t("channels.field.mentionOnly")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
||||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
interface FeishuFormProps {
|
interface FeishuFormProps {
|
||||||
config: ChannelConfig
|
config: ChannelConfig
|
||||||
onChange: (key: string, value: unknown) => void
|
onChange: (key: string, value: unknown) => void
|
||||||
isEdit: boolean
|
configuredSecrets: string[]
|
||||||
fieldErrors?: Record<string, string>
|
fieldErrors?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,25 +29,15 @@ function asStringArray(value: unknown): string[] {
|
||||||
export function FeishuForm({
|
export function FeishuForm({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
isEdit,
|
configuredSecrets,
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
}: FeishuFormProps) {
|
}: FeishuFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const appSecretExtraHint =
|
|
||||||
isEdit && asString(config.app_secret)
|
|
||||||
? ` ${t("channels.field.secretHintSet")}`
|
|
||||||
: ""
|
|
||||||
const verificationExtraHint =
|
|
||||||
isEdit && asString(config.verification_token)
|
|
||||||
? ` ${t("channels.field.secretHintSet")}`
|
|
||||||
: ""
|
|
||||||
const encryptExtraHint =
|
|
||||||
isEdit && asString(config.encrypt_key)
|
|
||||||
? ` ${t("channels.field.secretHintSet")}`
|
|
||||||
: ""
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
|
<Card className="py-3 shadow-sm">
|
||||||
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.appId")}
|
label={t("channels.field.appId")}
|
||||||
required
|
required
|
||||||
|
|
@ -63,51 +54,56 @@ export function FeishuForm({
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.appSecret")}
|
label={t("channels.field.appSecret")}
|
||||||
required
|
required
|
||||||
hint={`${t("channels.form.desc.appSecret")}${appSecretExtraHint}`}
|
hint={t("channels.form.desc.appSecret")}
|
||||||
error={fieldErrors.app_secret}
|
error={fieldErrors.app_secret}
|
||||||
>
|
>
|
||||||
<KeyInput
|
<KeyInput
|
||||||
value={asString(config._app_secret)}
|
value={asString(config._app_secret)}
|
||||||
onChange={(v) => onChange("_app_secret", v)}
|
onChange={(v) => onChange("_app_secret", v)}
|
||||||
placeholder={maskedSecretPlaceholder(
|
placeholder={getSecretInputPlaceholder(
|
||||||
config.app_secret,
|
configuredSecrets,
|
||||||
|
"app_secret",
|
||||||
|
t("channels.field.secretHintSet"),
|
||||||
t("channels.field.secretPlaceholder"),
|
t("channels.field.secretPlaceholder"),
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="py-3 shadow-sm">
|
||||||
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.verificationToken")}
|
label={t("channels.field.verificationToken")}
|
||||||
hint={`${t("channels.form.desc.verificationToken")}${verificationExtraHint}`}
|
hint={t("channels.form.desc.verificationToken")}
|
||||||
>
|
>
|
||||||
<KeyInput
|
<KeyInput
|
||||||
value={asString(config._verification_token)}
|
value={asString(config._verification_token)}
|
||||||
onChange={(v) => onChange("_verification_token", v)}
|
onChange={(v) => onChange("_verification_token", v)}
|
||||||
placeholder={maskedSecretPlaceholder(
|
placeholder={getSecretInputPlaceholder(
|
||||||
config.verification_token,
|
configuredSecrets,
|
||||||
|
"verification_token",
|
||||||
|
t("channels.field.secretHintSet"),
|
||||||
t("channels.field.secretPlaceholder"),
|
t("channels.field.secretPlaceholder"),
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.encryptKey")}
|
label={t("channels.field.encryptKey")}
|
||||||
hint={`${t("channels.form.desc.encryptKey")}${encryptExtraHint}`}
|
hint={t("channels.form.desc.encryptKey")}
|
||||||
>
|
>
|
||||||
<KeyInput
|
<KeyInput
|
||||||
value={asString(config._encrypt_key)}
|
value={asString(config._encrypt_key)}
|
||||||
onChange={(v) => onChange("_encrypt_key", v)}
|
onChange={(v) => onChange("_encrypt_key", v)}
|
||||||
placeholder={maskedSecretPlaceholder(
|
placeholder={getSecretInputPlaceholder(
|
||||||
config.encrypt_key,
|
configuredSecrets,
|
||||||
|
"encrypt_key",
|
||||||
|
t("channels.field.secretHintSet"),
|
||||||
t("channels.field.secretPlaceholder"),
|
t("channels.field.secretPlaceholder"),
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<SwitchCardField
|
|
||||||
label={t("channels.field.isLark")}
|
|
||||||
hint={t("channels.form.desc.isLark")}
|
|
||||||
checked={asBool(config.is_lark)}
|
|
||||||
onCheckedChange={(checked) => onChange("is_lark", checked)}
|
|
||||||
/>
|
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.allowFrom")}
|
label={t("channels.field.allowFrom")}
|
||||||
hint={t("channels.form.desc.allowFrom")}
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
|
|
@ -126,6 +122,18 @@ export function FeishuForm({
|
||||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("channels.field.isLark")}
|
||||||
|
hint={t("channels.form.desc.isLark")}
|
||||||
|
checked={asBool(config.is_lark)}
|
||||||
|
onCheckedChange={(checked) => onChange("is_lark", checked)}
|
||||||
|
ariaLabel={t("channels.field.isLark")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,23 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import {
|
||||||
|
getSecretInputPlaceholder,
|
||||||
|
isSecretField,
|
||||||
|
} from "@/components/channels/channel-config-fields"
|
||||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
interface GenericFormProps {
|
interface GenericFormProps {
|
||||||
config: ChannelConfig
|
config: ChannelConfig
|
||||||
onChange: (key: string, value: unknown) => void
|
onChange: (key: string, value: unknown) => void
|
||||||
isEdit: boolean
|
configuredSecrets?: string[]
|
||||||
hiddenKeys?: string[]
|
hiddenKeys?: string[]
|
||||||
requiredKeys?: string[]
|
requiredKeys?: string[]
|
||||||
fieldErrors?: Record<string, string>
|
fieldErrors?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secret field names that should use masked input.
|
|
||||||
const SECRET_FIELDS = new Set([
|
|
||||||
"token",
|
|
||||||
"app_secret",
|
|
||||||
"client_secret",
|
|
||||||
"corp_secret",
|
|
||||||
"channel_secret",
|
|
||||||
"channel_access_token",
|
|
||||||
"access_token",
|
|
||||||
"bot_token",
|
|
||||||
"app_token",
|
|
||||||
"encoding_aes_key",
|
|
||||||
"encrypt_key",
|
|
||||||
"verification_token",
|
|
||||||
"secret",
|
|
||||||
"password",
|
|
||||||
"nickserv_password",
|
|
||||||
"sasl_password",
|
|
||||||
])
|
|
||||||
|
|
||||||
// Fields to skip in the generic form (handled by enabled toggle or internal).
|
// Fields to skip in the generic form (handled by enabled toggle or internal).
|
||||||
const SKIP_FIELDS = new Set(["enabled", "reasoning_channel_id"])
|
const SKIP_FIELDS = new Set(["enabled", "reasoning_channel_id"])
|
||||||
|
|
||||||
|
|
@ -83,7 +67,7 @@ function asBool(value: unknown): boolean {
|
||||||
export function GenericForm({
|
export function GenericForm({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
isEdit,
|
configuredSecrets = [],
|
||||||
hiddenKeys = [],
|
hiddenKeys = [],
|
||||||
requiredKeys = [],
|
requiredKeys = [],
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
|
|
@ -96,7 +80,7 @@ export function GenericForm({
|
||||||
const placeholderConfig = asRecord(config.placeholder)
|
const placeholderConfig = asRecord(config.placeholder)
|
||||||
const placeholderEnabled = asBool(placeholderConfig.enabled)
|
const placeholderEnabled = asBool(placeholderConfig.enabled)
|
||||||
|
|
||||||
const fields = Object.keys(config).filter(
|
const rawFields = Object.keys(config).filter(
|
||||||
(k) =>
|
(k) =>
|
||||||
!k.startsWith("_") &&
|
!k.startsWith("_") &&
|
||||||
!SKIP_FIELDS.has(k) &&
|
!SKIP_FIELDS.has(k) &&
|
||||||
|
|
@ -160,26 +144,27 @@ export function GenericForm({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const renderField = (key: string) => {
|
||||||
<div className="space-y-5">
|
|
||||||
{fields.map((key) => {
|
|
||||||
const isRequired = requiredFieldSet.has(key)
|
const isRequired = requiredFieldSet.has(key)
|
||||||
if (SECRET_FIELDS.has(key)) {
|
if (isSecretField(key)) {
|
||||||
const editKey = `_${key}`
|
const editKey = `_${key}`
|
||||||
const extraHint =
|
|
||||||
isEdit && config[key] ? ` ${t("channels.field.secretHintSet")}` : ""
|
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
key={key}
|
key={key}
|
||||||
label={formatLabel(key)}
|
label={formatLabel(key)}
|
||||||
required={isRequired}
|
required={isRequired}
|
||||||
hint={`${buildHint(key)}${extraHint}`}
|
hint={buildHint(key)}
|
||||||
error={fieldErrors[key]}
|
error={fieldErrors[key]}
|
||||||
>
|
>
|
||||||
<KeyInput
|
<KeyInput
|
||||||
value={asString(config[editKey])}
|
value={asString(config[editKey])}
|
||||||
onChange={(v) => onChange(editKey, v)}
|
onChange={(v) => onChange(editKey, v)}
|
||||||
placeholder={maskedSecretPlaceholder(config[key])}
|
placeholder={getSecretInputPlaceholder(
|
||||||
|
configuredSecrets,
|
||||||
|
key,
|
||||||
|
t("channels.field.secretHintSet"),
|
||||||
|
t("channels.field.secretPlaceholder"),
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
)
|
)
|
||||||
|
|
@ -236,7 +221,6 @@ export function GenericForm({
|
||||||
<Input
|
<Input
|
||||||
value={String(value ?? "")}
|
value={String(value ?? "")}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
// Attempt to preserve number types
|
|
||||||
const v = e.target.value
|
const v = e.target.value
|
||||||
if (typeof config[key] === "number") {
|
if (typeof config[key] === "number") {
|
||||||
onChange(key, v === "" ? 0 : Number(v))
|
onChange(key, v === "" ? 0 : Number(v))
|
||||||
|
|
@ -247,10 +231,56 @@ export function GenericForm({
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
)
|
)
|
||||||
})}
|
}
|
||||||
|
|
||||||
{/* Allow From field */}
|
const isBasicField = (key: string) => {
|
||||||
{config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && (
|
if (requiredFieldSet.has(key)) return true
|
||||||
|
if (
|
||||||
|
key.endsWith("id") ||
|
||||||
|
key.endsWith("token") ||
|
||||||
|
key.endsWith("secret") ||
|
||||||
|
key.endsWith("url") ||
|
||||||
|
key === "server" ||
|
||||||
|
key === "host" ||
|
||||||
|
key === "port"
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const basicFields = rawFields.filter(isBasicField)
|
||||||
|
const advancedFields = rawFields.filter((key) => !isBasicField(key))
|
||||||
|
|
||||||
|
const hasAdvancedContent =
|
||||||
|
advancedFields.length > 0 ||
|
||||||
|
(config.allow_from !== undefined && !hiddenFieldSet.has("allow_from")) ||
|
||||||
|
(config.allow_origins !== undefined &&
|
||||||
|
!hiddenFieldSet.has("allow_origins")) ||
|
||||||
|
(config.allow_token_query !== undefined &&
|
||||||
|
!hiddenFieldSet.has("allow_token_query")) ||
|
||||||
|
(config.group_trigger !== undefined &&
|
||||||
|
!hiddenFieldSet.has("group_trigger")) ||
|
||||||
|
(config.typing !== undefined && !hiddenFieldSet.has("typing")) ||
|
||||||
|
(config.placeholder !== undefined && !hiddenFieldSet.has("placeholder"))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{basicFields.length > 0 && (
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
|
{basicFields.map(renderField)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasAdvancedContent && (
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
|
{advancedFields.map(renderField)}
|
||||||
|
|
||||||
|
{config.allow_from !== undefined &&
|
||||||
|
!hiddenFieldSet.has("allow_from") && (
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.allowFrom")}
|
label={t("channels.field.allowFrom")}
|
||||||
hint={t("channels.form.desc.allowFrom")}
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
|
|
@ -295,6 +325,7 @@ export function GenericForm({
|
||||||
|
|
||||||
{config.allow_token_query !== undefined &&
|
{config.allow_token_query !== undefined &&
|
||||||
!hiddenFieldSet.has("allow_token_query") && (
|
!hiddenFieldSet.has("allow_token_query") && (
|
||||||
|
<div>
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={formatLabel("allow_token_query")}
|
label={formatLabel("allow_token_query")}
|
||||||
hint={buildHint("allow_token_query")}
|
hint={buildHint("allow_token_query")}
|
||||||
|
|
@ -304,11 +335,13 @@ export function GenericForm({
|
||||||
}
|
}
|
||||||
ariaLabel={formatLabel("allow_token_query")}
|
ariaLabel={formatLabel("allow_token_query")}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{config.group_trigger !== undefined &&
|
{config.group_trigger !== undefined &&
|
||||||
!hiddenFieldSet.has("group_trigger") && (
|
!hiddenFieldSet.has("group_trigger") && (
|
||||||
<>
|
<>
|
||||||
|
<div>
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("channels.field.groupTriggerMentionOnly")}
|
label={t("channels.field.groupTriggerMentionOnly")}
|
||||||
hint={t("channels.form.desc.groupTriggerMentionOnly")}
|
hint={t("channels.form.desc.groupTriggerMentionOnly")}
|
||||||
|
|
@ -321,12 +354,16 @@ export function GenericForm({
|
||||||
}
|
}
|
||||||
ariaLabel={t("channels.field.groupTriggerMentionOnly")}
|
ariaLabel={t("channels.field.groupTriggerMentionOnly")}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.groupTriggerPrefixes")}
|
label={t("channels.field.groupTriggerPrefixes")}
|
||||||
hint={t("channels.form.desc.groupTriggerPrefixes")}
|
hint={t("channels.form.desc.groupTriggerPrefixes")}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={asStringArray(groupTriggerConfig.prefixes).join(", ")}
|
value={asStringArray(groupTriggerConfig.prefixes).join(
|
||||||
|
", ",
|
||||||
|
)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange("group_trigger", {
|
onChange("group_trigger", {
|
||||||
...groupTriggerConfig,
|
...groupTriggerConfig,
|
||||||
|
|
@ -343,6 +380,7 @@ export function GenericForm({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{config.typing !== undefined && !hiddenFieldSet.has("typing") && (
|
{config.typing !== undefined && !hiddenFieldSet.has("typing") && (
|
||||||
|
<div>
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("channels.field.typingEnabled")}
|
label={t("channels.field.typingEnabled")}
|
||||||
hint={t("channels.form.desc.typingEnabled")}
|
hint={t("channels.form.desc.typingEnabled")}
|
||||||
|
|
@ -352,10 +390,12 @@ export function GenericForm({
|
||||||
}
|
}
|
||||||
ariaLabel={t("channels.field.typingEnabled")}
|
ariaLabel={t("channels.field.typingEnabled")}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{config.placeholder !== undefined &&
|
{config.placeholder !== undefined &&
|
||||||
!hiddenFieldSet.has("placeholder") && (
|
!hiddenFieldSet.has("placeholder") && (
|
||||||
|
<div>
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("channels.field.placeholderEnabled")}
|
label={t("channels.field.placeholderEnabled")}
|
||||||
hint={t("channels.form.desc.placeholderEnabled")}
|
hint={t("channels.form.desc.placeholderEnabled")}
|
||||||
|
|
@ -384,6 +424,10 @@ export function GenericForm({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SwitchCardField>
|
</SwitchCardField>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
||||||
import { Field, KeyInput } from "@/components/shared-form"
|
import { Field, KeyInput } from "@/components/shared-form"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
interface SlackFormProps {
|
interface SlackFormProps {
|
||||||
config: ChannelConfig
|
config: ChannelConfig
|
||||||
onChange: (key: string, value: unknown) => void
|
onChange: (key: string, value: unknown) => void
|
||||||
isEdit: boolean
|
configuredSecrets: string[]
|
||||||
fieldErrors?: Record<string, string>
|
fieldErrors?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,45 +25,53 @@ function asStringArray(value: unknown): string[] {
|
||||||
export function SlackForm({
|
export function SlackForm({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
isEdit,
|
configuredSecrets,
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
}: SlackFormProps) {
|
}: SlackFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const botTokenExtraHint =
|
|
||||||
isEdit && asString(config.bot_token)
|
|
||||||
? ` ${t("channels.field.secretHintSet")}`
|
|
||||||
: ""
|
|
||||||
const appTokenExtraHint =
|
|
||||||
isEdit && asString(config.app_token)
|
|
||||||
? ` ${t("channels.field.secretHintSet")}`
|
|
||||||
: ""
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.botToken")}
|
label={t("channels.field.botToken")}
|
||||||
required
|
required
|
||||||
hint={`${t("channels.form.desc.botToken")}${botTokenExtraHint}`}
|
hint={t("channels.form.desc.botToken")}
|
||||||
error={fieldErrors.bot_token}
|
error={fieldErrors.bot_token}
|
||||||
>
|
>
|
||||||
<KeyInput
|
<KeyInput
|
||||||
value={asString(config._bot_token)}
|
value={asString(config._bot_token)}
|
||||||
onChange={(v) => onChange("_bot_token", v)}
|
onChange={(v) => onChange("_bot_token", v)}
|
||||||
placeholder={maskedSecretPlaceholder(config.bot_token, "xoxb-xxxx")}
|
placeholder={getSecretInputPlaceholder(
|
||||||
|
configuredSecrets,
|
||||||
|
"bot_token",
|
||||||
|
t("channels.field.secretHintSet"),
|
||||||
|
"xoxb-xxxx",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.appToken")}
|
label={t("channels.field.appToken")}
|
||||||
hint={`${t("channels.form.desc.appToken")}${appTokenExtraHint}`}
|
hint={t("channels.form.desc.appToken")}
|
||||||
>
|
>
|
||||||
<KeyInput
|
<KeyInput
|
||||||
value={asString(config._app_token)}
|
value={asString(config._app_token)}
|
||||||
onChange={(v) => onChange("_app_token", v)}
|
onChange={(v) => onChange("_app_token", v)}
|
||||||
placeholder={maskedSecretPlaceholder(config.app_token, "xapp-xxxx")}
|
placeholder={getSecretInputPlaceholder(
|
||||||
|
configuredSecrets,
|
||||||
|
"app_token",
|
||||||
|
t("channels.field.secretHintSet"),
|
||||||
|
"xapp-xxxx",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.allowFrom")}
|
label={t("channels.field.allowFrom")}
|
||||||
hint={t("channels.form.desc.allowFrom")}
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
|
|
@ -81,6 +90,8 @@ export function SlackForm({
|
||||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
||||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
interface TelegramFormProps {
|
interface TelegramFormProps {
|
||||||
config: ChannelConfig
|
config: ChannelConfig
|
||||||
onChange: (key: string, value: unknown) => void
|
onChange: (key: string, value: unknown) => void
|
||||||
isEdit: boolean
|
configuredSecrets: string[]
|
||||||
fieldErrors?: Record<string, string>
|
fieldErrors?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,31 +36,31 @@ function asBool(value: unknown): boolean {
|
||||||
export function TelegramForm({
|
export function TelegramForm({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
isEdit,
|
configuredSecrets,
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
}: TelegramFormProps) {
|
}: TelegramFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const typingConfig = asRecord(config.typing)
|
const typingConfig = asRecord(config.typing)
|
||||||
const placeholderConfig = asRecord(config.placeholder)
|
const placeholderConfig = asRecord(config.placeholder)
|
||||||
const placeholderEnabled = asBool(placeholderConfig.enabled)
|
const placeholderEnabled = asBool(placeholderConfig.enabled)
|
||||||
const tokenExtraHint =
|
|
||||||
isEdit && asString(config.token)
|
|
||||||
? ` ${t("channels.field.secretHintSet")}`
|
|
||||||
: ""
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.token")}
|
label={t("channels.field.token")}
|
||||||
required
|
required
|
||||||
hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
|
hint={t("channels.form.desc.token")}
|
||||||
error={fieldErrors.token}
|
error={fieldErrors.token}
|
||||||
>
|
>
|
||||||
<KeyInput
|
<KeyInput
|
||||||
value={asString(config._token)}
|
value={asString(config._token)}
|
||||||
onChange={(v) => onChange("_token", v)}
|
onChange={(v) => onChange("_token", v)}
|
||||||
placeholder={maskedSecretPlaceholder(
|
placeholder={getSecretInputPlaceholder(
|
||||||
config.token,
|
configuredSecrets,
|
||||||
|
"token",
|
||||||
|
t("channels.field.secretHintSet"),
|
||||||
t("channels.field.tokenPlaceholder"),
|
t("channels.field.tokenPlaceholder"),
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -75,6 +76,11 @@ export function TelegramForm({
|
||||||
placeholder="https://api.telegram.org"
|
placeholder="https://api.telegram.org"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.proxy")}
|
label={t("channels.field.proxy")}
|
||||||
hint={t("channels.form.desc.proxy")}
|
hint={t("channels.form.desc.proxy")}
|
||||||
|
|
@ -104,6 +110,7 @@ export function TelegramForm({
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<div>
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("channels.field.typingEnabled")}
|
label={t("channels.field.typingEnabled")}
|
||||||
hint={t("channels.form.desc.typingEnabled")}
|
hint={t("channels.form.desc.typingEnabled")}
|
||||||
|
|
@ -113,7 +120,9 @@ export function TelegramForm({
|
||||||
}
|
}
|
||||||
ariaLabel={t("channels.field.typingEnabled")}
|
ariaLabel={t("channels.field.typingEnabled")}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("channels.field.placeholderEnabled")}
|
label={t("channels.field.placeholderEnabled")}
|
||||||
hint={t("channels.form.desc.placeholderEnabled")}
|
hint={t("channels.form.desc.placeholderEnabled")}
|
||||||
|
|
@ -143,5 +152,8 @@ export function TelegramForm({
|
||||||
)}
|
)}
|
||||||
</SwitchCardField>
|
</SwitchCardField>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,13 @@ import { useTranslation } from "react-i18next"
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
import { patchAppConfig, pollWecomFlow, startWecomFlow } from "@/api/channels"
|
import { patchAppConfig, pollWecomFlow, startWecomFlow } from "@/api/channels"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
|
||||||
type BindingState =
|
type BindingState =
|
||||||
|
|
@ -329,39 +336,32 @@ export function WecomForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
|
<div className="bg-card text-card-foreground border-border/60 flex items-center justify-between rounded-xl border px-6 py-4 shadow-sm">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<p className="text-sm font-medium">{t("channels.page.enableLabel")}</p>
|
||||||
<div>
|
<div className="flex flex-col items-end gap-2">
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{t("channels.page.enableLabel")}
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
||||||
{isBound
|
|
||||||
? t("channels.wecom.enableDesc")
|
|
||||||
: t("channels.wecom.enableBindFirst")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
disabled={!isBound || toggleSaving}
|
disabled={!isBound || toggleSaving}
|
||||||
onCheckedChange={(checked) => void handleEnabledChange(checked)}
|
onCheckedChange={(checked) => void handleEnabledChange(checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{toggleError && (
|
{toggleError && (
|
||||||
<p className="text-destructive mt-2 text-sm">{toggleError}</p>
|
<p className="text-destructive max-w-60 text-right text-xs leading-normal">
|
||||||
|
{toggleError}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-border/60 bg-muted/30 rounded-xl border">
|
<Card className="shadow-sm">
|
||||||
<div className="border-border/60 border-b px-4 py-3">
|
<CardHeader className="border-border/60 border-b px-6">
|
||||||
<p className="text-sm font-medium">{t("channels.wecom.bindTitle")}</p>
|
<CardTitle className="text-foreground text-sm font-medium">
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
{t("channels.wecom.bindTitle")}
|
||||||
{t("channels.wecom.bindDesc")}
|
</CardTitle>
|
||||||
</p>
|
<CardDescription>{t("channels.wecom.bindDesc")}</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
{renderBindSection()}
|
<CardContent className="p-0">{renderBindSection()}</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,13 @@ import type { ChannelConfig } from "@/api/channels"
|
||||||
import { pollWeixinFlow, startWeixinFlow } from "@/api/channels"
|
import { pollWeixinFlow, startWeixinFlow } from "@/api/channels"
|
||||||
import { Field } from "@/components/shared-form"
|
import { Field } from "@/components/shared-form"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
type BindingState =
|
type BindingState =
|
||||||
|
|
@ -301,21 +308,19 @@ export function WeixinForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
{/* QR Bind Section */}
|
<Card className="shadow-sm">
|
||||||
<div className="border-border/60 bg-muted/30 rounded-xl border">
|
<CardHeader className="border-border/60 border-b px-6">
|
||||||
<div className="border-border/60 border-b px-4 py-3">
|
<CardTitle className="text-foreground text-sm font-medium">
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{t("channels.weixin.bindTitle")}
|
{t("channels.weixin.bindTitle")}
|
||||||
</p>
|
</CardTitle>
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
<CardDescription>{t("channels.weixin.bindDesc")}</CardDescription>
|
||||||
{t("channels.weixin.bindDesc")}
|
</CardHeader>
|
||||||
</p>
|
<CardContent className="p-0">{renderBindSection()}</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
{renderBindSection()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* allow_from */}
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.allowFrom")}
|
label={t("channels.field.allowFrom")}
|
||||||
hint={t("channels.form.desc.allowFrom")}
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
|
|
@ -335,7 +340,6 @@ export function WeixinForm({
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{/* proxy */}
|
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.proxy")}
|
label={t("channels.field.proxy")}
|
||||||
hint={t("channels.form.desc.proxy")}
|
hint={t("channels.form.desc.proxy")}
|
||||||
|
|
@ -346,6 +350,8 @@ export function WeixinForm({
|
||||||
placeholder="http://localhost:7890"
|
placeholder="http://localhost:7890"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { IconCode, IconDeviceFloppy } from "@tabler/icons-react"
|
import { IconCode, IconDeviceFloppy, IconTag } from "@tabler/icons-react"
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { Link } from "@tanstack/react-router"
|
import { Link } from "@tanstack/react-router"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
|
@ -10,6 +10,7 @@ import { launcherFetch } from "@/api/http"
|
||||||
import {
|
import {
|
||||||
getAutoStartStatus,
|
getAutoStartStatus,
|
||||||
getLauncherConfig,
|
getLauncherConfig,
|
||||||
|
getSystemVersionInfo,
|
||||||
setAutoStartEnabled as updateAutoStartEnabled,
|
setAutoStartEnabled as updateAutoStartEnabled,
|
||||||
setLauncherConfig as updateLauncherConfig,
|
setLauncherConfig as updateLauncherConfig,
|
||||||
} from "@/api/system"
|
} from "@/api/system"
|
||||||
|
|
@ -32,6 +33,7 @@ import {
|
||||||
parseMultilineList,
|
parseMultilineList,
|
||||||
} from "@/components/config/form-model"
|
} from "@/components/config/form-model"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { refreshGatewayState } from "@/store/gateway"
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
|
|
@ -64,6 +66,12 @@ export function ConfigPage() {
|
||||||
queryFn: getLauncherConfig,
|
queryFn: getLauncherConfig,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: versionInfo } = useQuery({
|
||||||
|
queryKey: ["system", "version"],
|
||||||
|
queryFn: getSystemVersionInfo,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: autoStartStatus,
|
data: autoStartStatus,
|
||||||
isLoading: isAutoStartLoading,
|
isLoading: isAutoStartLoading,
|
||||||
|
|
@ -297,6 +305,17 @@ export function ConfigPage() {
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t("navigation.config")}
|
title={t("navigation.config")}
|
||||||
|
titleExtra={
|
||||||
|
versionInfo && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-1 font-mono text-[11px] font-normal opacity-80"
|
||||||
|
>
|
||||||
|
<IconTag className="size-3 opacity-70" />
|
||||||
|
{versionInfo.version}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
children={
|
children={
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link to="/config/raw">
|
<Link to="/config/raw">
|
||||||
|
|
|
||||||
|
|
@ -34,23 +34,28 @@ export function Field({
|
||||||
}: FieldProps) {
|
}: FieldProps) {
|
||||||
if (layout === "setting-row") {
|
if (layout === "setting-row") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_minmax(240px,320px)] md:items-center md:gap-6">
|
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[280px_minmax(0,1fr)] md:items-center md:gap-8">
|
||||||
<div className="max-w-full space-y-1 md:max-w-[clamp(18rem,42vw,28rem)]">
|
<div className="w-full min-w-0">
|
||||||
<FieldLabel>
|
<FieldLabel className="leading-relaxed break-words whitespace-normal">
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="text-destructive ml-1">*</span>}
|
{required && <span className="text-destructive ml-1">*</span>}
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
{hint && (
|
{hint && (
|
||||||
<FieldDescription className="text-xs leading-normal break-words">
|
<FieldDescription className="mt-1 text-xs leading-relaxed break-words whitespace-normal">
|
||||||
{hint}
|
{hint}
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("w-full md:justify-self-center", controlClassName)}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full md:max-w-[28rem] md:justify-self-end",
|
||||||
|
controlClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<FieldDescription className="text-destructive text-xs leading-normal md:col-start-2">
|
<FieldDescription className="text-destructive text-xs leading-normal md:col-start-2 md:justify-self-end">
|
||||||
{error}
|
{error}
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
)}
|
)}
|
||||||
|
|
@ -125,6 +130,7 @@ interface SwitchCardFieldProps {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
layout?: FieldLayout
|
layout?: FieldLayout
|
||||||
|
transparent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SwitchCardField({
|
export function SwitchCardField({
|
||||||
|
|
@ -137,19 +143,22 @@ export function SwitchCardField({
|
||||||
disabled,
|
disabled,
|
||||||
children,
|
children,
|
||||||
layout = "default",
|
layout = "default",
|
||||||
|
transparent,
|
||||||
}: SwitchCardFieldProps) {
|
}: SwitchCardFieldProps) {
|
||||||
if (layout === "setting-row") {
|
if (layout === "setting-row") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_auto] md:items-center md:gap-6">
|
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[280px_minmax(0,1fr)] md:items-center md:gap-8">
|
||||||
<div className="max-w-full min-w-0 md:max-w-[clamp(18rem,42vw,28rem)]">
|
<div className="w-full min-w-0">
|
||||||
<p className="text-sm font-medium">{label}</p>
|
<p className="text-sm leading-relaxed font-medium break-words whitespace-normal">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
{hint && (
|
{hint && (
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs leading-normal break-words">
|
<p className="text-muted-foreground mt-1 text-xs leading-relaxed break-words whitespace-normal">
|
||||||
{hint}
|
{hint}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center md:justify-self-center">
|
<div className="flex items-center md:justify-self-end">
|
||||||
<Switch
|
<Switch
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onCheckedChange={onCheckedChange}
|
onCheckedChange={onCheckedChange}
|
||||||
|
|
@ -157,9 +166,13 @@ export function SwitchCardField({
|
||||||
aria-label={ariaLabel ?? label}
|
aria-label={ariaLabel ?? label}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{children && <div className="md:col-start-2">{children}</div>}
|
{children && (
|
||||||
|
<div className="mt-1 flex w-full justify-end md:col-start-2">
|
||||||
|
<div className="w-full md:max-w-[28rem]">{children}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-destructive text-xs leading-normal md:col-start-2">
|
<p className="text-destructive text-xs leading-normal md:col-start-2 md:justify-self-end">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -168,7 +181,11 @@ export function SwitchCardField({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
|
<div
|
||||||
|
className={cn(
|
||||||
|
transparent ? "py-1" : "border-border/60 rounded-lg border px-4 py-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium">{label}</p>
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
|
@ -185,7 +202,7 @@ export function SwitchCardField({
|
||||||
aria-label={ariaLabel ?? label}
|
aria-label={ariaLabel ?? label}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{children && <div className="mt-3">{children}</div>}
|
{children && <div className="mt-4">{children}</div>}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-destructive mt-2 text-xs leading-normal">{error}</p>
|
<p className="text-destructive mt-2 text-xs leading-normal">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
49
web/frontend/src/components/ui/badge.tsx
Normal file
49
web/frontend/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
|
|
@ -244,10 +244,6 @@
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"loadError": "Failed to load channels",
|
"loadError": "Failed to load channels",
|
||||||
"edit": "Configure {{name}}",
|
|
||||||
"status": {
|
|
||||||
"configured": "Configured"
|
|
||||||
},
|
|
||||||
"name": {
|
"name": {
|
||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"discord": "Discord",
|
"discord": "Discord",
|
||||||
|
|
@ -267,8 +263,6 @@
|
||||||
"weixin": "WeChat"
|
"weixin": "WeChat"
|
||||||
},
|
},
|
||||||
"weixin": {
|
"weixin": {
|
||||||
"warningTitle": "Testing phase, use with caution",
|
|
||||||
"warningDesc": "The WeChat channel is still experimental and may carry a risk of account suspension. Use it only if you understand and accept the risk.",
|
|
||||||
"bindTitle": "WeChat Account Binding",
|
"bindTitle": "WeChat Account Binding",
|
||||||
"bindDesc": "Scan the QR code with WeChat to bind your personal account.",
|
"bindDesc": "Scan the QR code with WeChat to bind your personal account.",
|
||||||
"bind": "Bind WeChat",
|
"bind": "Bind WeChat",
|
||||||
|
|
@ -286,8 +280,6 @@
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"bindTitle": "WeCom Binding",
|
"bindTitle": "WeCom Binding",
|
||||||
"bindDesc": "Scan the QR code with WeCom to bind your AI Bot.",
|
"bindDesc": "Scan the QR code with WeCom to bind your AI Bot.",
|
||||||
"enableDesc": "Once bound, you can enable or disable the channel here.",
|
|
||||||
"enableBindFirst": "Bind the bot first, then enable the channel.",
|
|
||||||
"bind": "Bind WeCom",
|
"bind": "Bind WeCom",
|
||||||
"rebind": "Re-bind",
|
"rebind": "Re-bind",
|
||||||
"bound": "WeCom Bound",
|
"bound": "WeCom Bound",
|
||||||
|
|
@ -329,7 +321,6 @@
|
||||||
"notFound": "Channel \"{{name}}\" is not supported.",
|
"notFound": "Channel \"{{name}}\" is not supported.",
|
||||||
"saveSuccess": "Channel configuration saved.",
|
"saveSuccess": "Channel configuration saved.",
|
||||||
"saveError": "Failed to save channel configuration",
|
"saveError": "Failed to save channel configuration",
|
||||||
"enabled": "enabled",
|
|
||||||
"docLink": "Documentation",
|
"docLink": "Documentation",
|
||||||
"enableLabel": "Enable channel",
|
"enableLabel": "Enable channel",
|
||||||
"restartRequiredTitle": "Gateway restart required",
|
"restartRequiredTitle": "Gateway restart required",
|
||||||
|
|
|
||||||
|
|
@ -244,10 +244,6 @@
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"loadError": "加载频道列表失败",
|
"loadError": "加载频道列表失败",
|
||||||
"edit": "配置 {{name}}",
|
|
||||||
"status": {
|
|
||||||
"configured": "已配置"
|
|
||||||
},
|
|
||||||
"name": {
|
"name": {
|
||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"discord": "Discord",
|
"discord": "Discord",
|
||||||
|
|
@ -267,8 +263,6 @@
|
||||||
"weixin": "微信"
|
"weixin": "微信"
|
||||||
},
|
},
|
||||||
"weixin": {
|
"weixin": {
|
||||||
"warningTitle": "测试阶段,请谨慎使用",
|
|
||||||
"warningDesc": "微信 Channel 当前仍处于测试阶段,存在封号风险。请仅在充分了解风险的前提下使用。",
|
|
||||||
"bindTitle": "微信账号绑定",
|
"bindTitle": "微信账号绑定",
|
||||||
"bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
|
"bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
|
||||||
"bind": "绑定微信",
|
"bind": "绑定微信",
|
||||||
|
|
@ -286,8 +280,6 @@
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"bindTitle": "企业微信绑定",
|
"bindTitle": "企业微信绑定",
|
||||||
"bindDesc": "使用企业微信扫描二维码以绑定您的 AI Bot。",
|
"bindDesc": "使用企业微信扫描二维码以绑定您的 AI Bot。",
|
||||||
"enableDesc": "绑定后可在这里直接启用或停用频道。",
|
|
||||||
"enableBindFirst": "请先完成绑定,然后再启用频道。",
|
|
||||||
"bind": "绑定企业微信",
|
"bind": "绑定企业微信",
|
||||||
"rebind": "重新绑定",
|
"rebind": "重新绑定",
|
||||||
"bound": "企业微信已绑定",
|
"bound": "企业微信已绑定",
|
||||||
|
|
@ -323,13 +315,12 @@
|
||||||
"allowOrigins": "允许来源域名",
|
"allowOrigins": "允许来源域名",
|
||||||
"allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173",
|
"allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173",
|
||||||
"secretPlaceholder": "输入密钥",
|
"secretPlaceholder": "输入密钥",
|
||||||
"secretHintSet": "已设置密钥,留空表示不修改。"
|
"secretHintSet": "配置已保存,留空表示不修改"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"notFound": "不支持频道“{{name}}”。",
|
"notFound": "不支持频道“{{name}}”。",
|
||||||
"saveSuccess": "频道配置已保存。",
|
"saveSuccess": "频道配置已保存。",
|
||||||
"saveError": "保存频道配置失败",
|
"saveError": "保存频道配置失败",
|
||||||
"enabled": "已启用",
|
|
||||||
"docLink": "配置文档",
|
"docLink": "配置文档",
|
||||||
"enableLabel": "启用频道",
|
"enableLabel": "启用频道",
|
||||||
"restartRequiredTitle": "需要重启服务",
|
"restartRequiredTitle": "需要重启服务",
|
||||||
|
|
@ -337,58 +328,58 @@
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"desc": {
|
"desc": {
|
||||||
"token": "机器人访问令牌,用于连接平台 API。",
|
"token": "机器人访问令牌,用于连接平台 API",
|
||||||
"botToken": "Bot Token,用于发送与接收消息。",
|
"botToken": "Bot Token,用于发送与接收消息",
|
||||||
"appToken": "App Token,用于 Socket 模式连接。",
|
"appToken": "App Token,用于 Socket 模式连接。",
|
||||||
"appId": "应用唯一标识,用于平台鉴权。",
|
"appId": "应用唯一标识,用于平台鉴权",
|
||||||
"appSecret": "应用密钥,用于请求签名和鉴权。",
|
"appSecret": "应用密钥,用于请求签名和鉴权",
|
||||||
"verificationToken": "事件回调验证令牌。",
|
"verificationToken": "事件回调验证令牌",
|
||||||
"encryptKey": "消息加密密钥,用于解密回调内容。",
|
"encryptKey": "消息加密密钥,用于解密回调内容",
|
||||||
"baseUrl": "平台 API 地址,默认使用官方地址。",
|
"baseUrl": "平台 API 地址,默认使用官方地址",
|
||||||
"proxy": "HTTP 代理地址,用于网络访问。",
|
"proxy": "HTTP 代理地址,用于网络访问",
|
||||||
"mentionOnly": "在群聊中仅当明确提及时才响应。",
|
"mentionOnly": "在群聊中仅当明确提及时才响应",
|
||||||
"typingEnabled": "在生成回复时显示“正在输入”状态。",
|
"typingEnabled": "在生成回复时显示“正在输入”状态",
|
||||||
"placeholderEnabled": "在最终回复发送前,先发送临时占位消息。",
|
"placeholderEnabled": "在最终回复发送前,先发送临时占位消息",
|
||||||
"groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应。",
|
"groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应",
|
||||||
"groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔。",
|
"groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔",
|
||||||
"isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn)。",
|
"isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn)",
|
||||||
"allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔。",
|
"allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔",
|
||||||
"allowOrigins": "允许访问的来源域名,多个值用逗号分隔。",
|
"allowOrigins": "允许访问的来源域名,多个值用逗号分隔",
|
||||||
"wsUrl": "WebSocket 服务地址。",
|
"wsUrl": "WebSocket 服务地址",
|
||||||
"reconnectInterval": "断线后的重连间隔(秒)。",
|
"reconnectInterval": "断线后的重连间隔(秒)",
|
||||||
"bridgeUrl": "桥接服务地址。",
|
"bridgeUrl": "桥接服务地址",
|
||||||
"sessionStorePath": "本地会话存储目录路径。",
|
"sessionStorePath": "本地会话存储目录路径",
|
||||||
"useNative": "是否使用原生客户端模式连接。",
|
"useNative": "是否使用原生客户端模式连接",
|
||||||
"host": "服务监听主机地址。",
|
"host": "服务监听主机地址",
|
||||||
"port": "服务监听端口。",
|
"port": "服务监听端口",
|
||||||
"homeserver": "Matrix homeserver 地址。",
|
"homeserver": "Matrix homeserver 地址",
|
||||||
"userId": "账号 ID。",
|
"userId": "账号 ID",
|
||||||
"deviceId": "设备 ID。",
|
"deviceId": "设备 ID",
|
||||||
"joinOnInvite": "收到邀请时是否自动加入房间。",
|
"joinOnInvite": "收到邀请时是否自动加入房间",
|
||||||
"clientId": "应用客户端 ID,用于平台鉴权。",
|
"clientId": "应用客户端 ID,用于平台鉴权",
|
||||||
"corpId": "企业 ID。",
|
"corpId": "企业 ID",
|
||||||
"agentId": "企业应用 Agent ID。",
|
"agentId": "企业应用 Agent ID",
|
||||||
"webhookUrl": "Webhook 完整地址。",
|
"webhookUrl": "Webhook 完整地址",
|
||||||
"webhookHost": "Webhook 监听主机。",
|
"webhookHost": "Webhook 监听主机",
|
||||||
"webhookPort": "Webhook 监听端口。",
|
"webhookPort": "Webhook 监听端口",
|
||||||
"webhookPath": "Webhook 路径。",
|
"webhookPath": "Webhook 路径",
|
||||||
"replyTimeout": "回复超时时间(秒)。",
|
"replyTimeout": "回复超时时间(秒)",
|
||||||
"maxSteps": "最大步骤数。",
|
"maxSteps": "最大步骤数",
|
||||||
"welcomeMessage": "新会话欢迎语内容。",
|
"welcomeMessage": "新会话欢迎语内容",
|
||||||
"allowTokenQuery": "是否允许 URL Query 方式传递 Token。",
|
"allowTokenQuery": "是否允许 URL Query 方式传递 Token",
|
||||||
"pingInterval": "连接心跳间隔(秒)。",
|
"pingInterval": "连接心跳间隔(秒)",
|
||||||
"readTimeout": "读取超时时间(秒)。",
|
"readTimeout": "读取超时时间(秒)",
|
||||||
"writeTimeout": "写入超时时间(秒)。",
|
"writeTimeout": "写入超时时间(秒)",
|
||||||
"maxConnections": "最大并发连接数。",
|
"maxConnections": "最大并发连接数",
|
||||||
"server": "IRC 服务器地址。",
|
"server": "IRC 服务器地址",
|
||||||
"tls": "是否启用 TLS 连接。",
|
"tls": "是否启用 TLS 连接",
|
||||||
"nick": "机器人昵称。",
|
"nick": "机器人昵称",
|
||||||
"user": "IRC 用户名。",
|
"user": "IRC 用户名",
|
||||||
"realName": "显示名称。",
|
"realName": "显示名称",
|
||||||
"channels": "要加入的 IRC 频道列表。",
|
"channels": "要加入的 IRC 频道列表",
|
||||||
"requestCaps": "连接时请求的 IRC 扩展能力列表。",
|
"requestCaps": "连接时请求的 IRC 扩展能力列表",
|
||||||
"maxBase64FileSizeMiB": "本地文件转为 base64 上传的最大体积,单位 MiB;0 表示不限制,仅影响本地文件,不影响 URL 直传。",
|
"maxBase64FileSizeMiB": "本地文件转为 base64 上传的最大体积,单位 MiB;0 表示不限制,仅影响本地文件,不影响 URL 直传",
|
||||||
"genericField": "用于配置{{field}}。"
|
"genericField": "用于配置{{field}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue