merge upstream/main: incorporate channel refactor, launcher, and modernizations
Resolve 65 file conflicts merging upstream/main into local branch. Key decisions: - Accept upstream's channel interface refactoring (clean Channel interface) - Accept upstream's Cobra CLI restructuring, Launcher TUI, armv6 support - Accept upstream's HTTP client caching, resp.Body leak fix, regex precompile - Accept upstream's WeChat/WeCom resource leak fixes and context handling - Accept upstream's migrate API restructure (NewMigrateInstance pattern) - Accept upstream's Go 1.22+ syntax modernizations (for range, max/min) - Preserve local orchestration, plan mode, heartbeat, sandbox, spawn system - Preserve local SendWithID/MessageSenderWithID for status message dedup - Preserve local Stream/RPM/functional options in providers - Rename AGENT.md → AGENTS.md to match upstream template convention Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
1971b47ed9
112 changed files with 9946 additions and 2265 deletions
|
|
@ -28,7 +28,11 @@
|
|||
"Bash(gh api:*)",
|
||||
"Bash(gh run:*)",
|
||||
"Bash(gh pr:*)",
|
||||
"Bash(gofmt:*)"
|
||||
"Bash(gofmt:*)",
|
||||
"Bash(go vet:*)",
|
||||
"Bash(gofumpt:*)",
|
||||
"Bash(git merge:*)",
|
||||
"Bash(git checkout:*)"
|
||||
]
|
||||
},
|
||||
"remote": {
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -44,3 +44,6 @@ tasks/
|
|||
|
||||
# Added by goreleaser init:
|
||||
dist/
|
||||
|
||||
# Windows Application Icon/Resource
|
||||
*.syso
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ version: 2
|
|||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./cmd/picoclaw/...
|
||||
- go generate ./...
|
||||
- go install github.com/tc-hib/go-winres@latest
|
||||
- go-winres make --in cmd/picoclaw-launcher/winres/winres.json --out cmd/picoclaw-launcher/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
||||
|
||||
builds:
|
||||
- id: picoclaw
|
||||
|
|
@ -31,12 +33,67 @@ builds:
|
|||
- loong64
|
||||
- arm
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
main: ./cmd/picoclaw
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
|
||||
- id: picoclaw-launcher
|
||||
binary: picoclaw-launcher
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
tags:
|
||||
- stdjson
|
||||
ldflags:
|
||||
- -s -w
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
- loong64
|
||||
- arm
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
main: ./cmd/picoclaw-launcher
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
|
||||
- id: picoclaw-launcher-tui
|
||||
binary: picoclaw-launcher-tui
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
tags:
|
||||
- stdjson
|
||||
ldflags:
|
||||
- -s -w
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
- loong64
|
||||
- arm
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
main: ./cmd/picoclaw-launcher-tui
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
|
||||
dockers_v2:
|
||||
- id: picoclaw
|
||||
dockerfile: docker/Dockerfile.goreleaser
|
||||
|
|
@ -72,6 +129,10 @@ archives:
|
|||
|
||||
nfpms:
|
||||
- id: picoclaw
|
||||
builds:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
- picoclaw-launcher-tui
|
||||
package_name: picoclaw
|
||||
file_name_template: >-
|
||||
{{ .PackageName }}_
|
||||
|
|
|
|||
34
Makefile
34
Makefile
|
|
@ -109,6 +109,40 @@ build-whatsapp-native: generate
|
|||
@echo "Build complete"
|
||||
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||
|
||||
## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit)
|
||||
build-linux-arm: generate
|
||||
@echo "Building for linux/arm (GOARM=7)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
|
||||
|
||||
## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
|
||||
build-linux-arm64: generate
|
||||
@echo "Building for linux/arm64..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
||||
|
||||
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
|
||||
build-pi-zero: build-linux-arm build-linux-arm64
|
||||
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
|
||||
|
||||
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||
build-whatsapp-native: generate
|
||||
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||
@echo "Building for multiple platforms..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||
## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||
@echo "Build complete"
|
||||
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||
|
||||
## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit)
|
||||
build-linux-arm: generate
|
||||
@echo "Building for linux/arm (GOARM=7)..."
|
||||
|
|
|
|||
42
README.fr.md
42
README.fr.md
|
|
@ -456,8 +456,6 @@ picoclaw gateway
|
|||
"enabled": true,
|
||||
"channel_secret": "VOTRE_CHANNEL_SECRET",
|
||||
"channel_access_token": "VOTRE_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18791,
|
||||
"webhook_path": "/webhook/line",
|
||||
"allow_from": []
|
||||
}
|
||||
|
|
@ -470,12 +468,14 @@ picoclaw gateway
|
|||
LINE exige HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel :
|
||||
|
||||
```bash
|
||||
# Exemple avec ngrok
|
||||
ngrok http 18791
|
||||
# Exemple avec ngrok (tunnel vers le serveur Gateway partagé)
|
||||
ngrok http 18790
|
||||
```
|
||||
|
||||
Puis configurez l'URL du Webhook dans la LINE Developers Console sur `https://votre-domaine/webhook/line` et activez **Use webhook**.
|
||||
|
||||
> **Note** : Le webhook LINE est servi par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Si vous utilisez ngrok ou un proxy inverse, faites pointer le tunnel vers le port `18790`.
|
||||
|
||||
**4. Lancer**
|
||||
|
||||
```bash
|
||||
|
|
@ -484,7 +484,7 @@ picoclaw gateway
|
|||
|
||||
> Dans les discussions de groupe, le bot répond uniquement lorsqu'il est mentionné avec @. Les réponses citent le message original.
|
||||
|
||||
> **Docker Compose** : Ajoutez `ports: ["18791:18791"]` au service `picoclaw-gateway` pour exposer le port du webhook.
|
||||
> **Docker Compose** : Si vous avez besoin d'exposer le webhook LINE via Docker, mappez le port du Gateway partagé (par défaut `18790`) vers l'hôte, par exemple `ports: ["18790:18790"]`. Notez que le serveur Gateway sert les webhooks de tous les canaux à partir de ce port.
|
||||
|
||||
</details>
|
||||
|
||||
|
|
@ -515,8 +515,6 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour
|
|||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18793,
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"allow_from": []
|
||||
}
|
||||
|
|
@ -535,7 +533,7 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour
|
|||
**2. Configurer la réception des messages**
|
||||
|
||||
* Dans les détails de l'application, cliquez sur "Recevoir les Messages" → "Configurer l'API"
|
||||
* Définissez l'URL sur `http://your-server:18792/webhook/wecom-app`
|
||||
* Définissez l'URL sur `http://your-server:18790/webhook/wecom-app`
|
||||
* Générez le **Token** et l'**EncodingAESKey**
|
||||
|
||||
**3. Configurer**
|
||||
|
|
@ -550,8 +548,6 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour
|
|||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18792,
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": []
|
||||
}
|
||||
|
|
@ -565,7 +561,7 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour
|
|||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **Note** : WeCom App nécessite l'ouverture du port 18792 pour les callbacks webhook. Utilisez un proxy inverse pour HTTPS en production.
|
||||
> **Note** : Les callbacks webhook WeCom App sont servis par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Assurez-vous que le port `18790` est accessible ou utilisez un proxy inverse HTTPS en production.
|
||||
|
||||
</details>
|
||||
|
||||
|
|
@ -1071,34 +1067,10 @@ Pour le guide de migration détaillé, voir [docs/migration/model-list-migration
|
|||
| `picoclaw agent -m "..."` | Discuter avec l'agent |
|
||||
| `picoclaw agent` | Mode de discussion interactif |
|
||||
| `picoclaw gateway` | Démarrer la passerelle |
|
||||
| `picoclaw gateway --stats`| Démarrer avec suivi d'utilisation |
|
||||
| `picoclaw status` | Afficher le statut |
|
||||
| `picoclaw cron list` | Lister toutes les tâches planifiées |
|
||||
| `picoclaw cron add ...` | Ajouter une tâche planifiée |
|
||||
|
||||
### Commandes de Chat
|
||||
|
||||
Ces commandes slash peuvent être envoyées dans n'importe quel canal de chat (Telegram, Discord, etc.) et répondent instantanément sans consommer de tokens LLM :
|
||||
|
||||
| Commande | Description |
|
||||
| --- | --- |
|
||||
| `/help` | Afficher les commandes disponibles |
|
||||
| `/session` | Afficher les statistiques d'utilisation des tokens (nécessite `--stats`) |
|
||||
| `/session reset` | Réinitialiser les statistiques d'utilisation |
|
||||
| `/skills` | Lister les compétences disponibles |
|
||||
| `/skill <nom> [message]` | Invoquer une compétence |
|
||||
| `/show model` | Afficher le modèle actuel |
|
||||
| `/list channels` | Lister les canaux activés |
|
||||
| `/plan` | Afficher la progression du plan actuel |
|
||||
| `/plan <tâche>` | Démarrer un nouveau plan avec entretien |
|
||||
| `/plan clear` | Abandonner le plan actuel |
|
||||
|
||||
> **Note** : `/session` nécessite que la passerelle soit démarrée avec le flag `--stats`. Sans celui-ci, le suivi des statistiques est désactivé pour zéro surcharge.
|
||||
>
|
||||
> **Note** : `/skills` répond instantanément sans coût LLM. `/skill <nom>` injecte le SKILL.md dans le contexte pour le tour en cours uniquement, puis seul le nom de la compétence est conservé dans l'historique pour économiser la fenêtre de contexte.
|
||||
>
|
||||
> **Note** : `/plan` permet la planification structurée des tâches avec avancement automatique des phases. L'IA vous interview pour comprendre les exigences, organise le travail en phases et suit la progression via MEMORY.md.
|
||||
|
||||
### Tâches Planifiées / Rappels
|
||||
|
||||
PicoClaw prend en charge les rappels planifiés et les tâches récurrentes via l'outil `cron` :
|
||||
|
|
|
|||
77
README.ja.md
77
README.ja.md
|
|
@ -289,11 +289,6 @@ Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話でき
|
|||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
#### Mini App(ダッシュボード)
|
||||
|
||||
PicoClaw には Telegram Mini App が含まれており、チャット内から GUI ダッシュボードを利用できます。Config タブにはリアルタイムの **ログビューアー** があり、WebSocket で 3 種類のログストリーム(picoclaw / telego / dev-console)を表示します。コンポーネントフィルタ、スナップショット保存(tar.gz、14日後自動削除)にも対応しています。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
|
|
@ -426,8 +421,6 @@ picoclaw gateway
|
|||
"enabled": true,
|
||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18791,
|
||||
"webhook_path": "/webhook/line",
|
||||
"allow_from": []
|
||||
}
|
||||
|
|
@ -441,11 +434,13 @@ LINE の Webhook には HTTPS が必要です。リバースプロキシまた
|
|||
|
||||
```bash
|
||||
# ngrok の例
|
||||
ngrok http 18791
|
||||
ngrok http 18790
|
||||
```
|
||||
|
||||
LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。
|
||||
|
||||
> **注意**: LINE の Webhook は共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は Gateway のポートを公開するか、リバースプロキシを設定してください。
|
||||
|
||||
**4. 起動**
|
||||
|
||||
```bash
|
||||
|
|
@ -454,7 +449,7 @@ picoclaw gateway
|
|||
|
||||
> グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。
|
||||
|
||||
> **Docker Compose**: `picoclaw-gateway` サービスに `ports: ["18791:18791"]` を追加して Webhook ポートを公開してください。
|
||||
> **Docker Compose**: Gateway HTTP サーバーは共有の `127.0.0.1:18790` で Webhook を提供します。ホストからアクセスするには `picoclaw-gateway` サービスに `ports: ["18790:18790"]` を追加してください。
|
||||
|
||||
</details>
|
||||
|
||||
|
|
@ -485,13 +480,13 @@ PicoClaw は2種類の WeCom 統合をサポートしています:
|
|||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18793,
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> **注意**: WeCom Bot の Webhook 受信は共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は Gateway のポートを公開するか、HTTPS 用のリバースプロキシを設定してください。
|
||||
```
|
||||
|
||||
**クイックセットアップ - WeCom App:**
|
||||
|
|
@ -505,7 +500,7 @@ PicoClaw は2種類の WeCom 統合をサポートしています:
|
|||
**2. メッセージ受信を設定**
|
||||
|
||||
* アプリ詳細で "メッセージを受信" → "APIを設定" をクリック
|
||||
* URL を `http://your-server:18792/webhook/wecom-app` に設定
|
||||
* URL を `http://your-server:18790/webhook/wecom-app` に設定
|
||||
* **Token** と **EncodingAESKey** を生成
|
||||
|
||||
**3. 設定**
|
||||
|
|
@ -520,8 +515,6 @@ PicoClaw は2種類の WeCom 統合をサポートしています:
|
|||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18792,
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": []
|
||||
}
|
||||
|
|
@ -535,7 +528,7 @@ PicoClaw は2種類の WeCom 統合をサポートしています:
|
|||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **注意**: WeCom App は Webhook コールバック用にポート 18792 を開放する必要があります。本番環境では HTTPS 用のリバースプロキシを使用してください。
|
||||
> **注意**: WeCom App の Webhook コールバックは共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は HTTPS 用のリバースプロキシを設定してください。
|
||||
|
||||
</details>
|
||||
|
||||
|
|
@ -730,36 +723,6 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化
|
||||
- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔変更
|
||||
|
||||
### モデルフォールバック
|
||||
|
||||
`model_fallbacks` はプライマリモデルがリトライ可能なエラー(レート制限、サーバーエラー等)を返した時に起動するフォールバックチェーンです。**異なるプロバイダー**をターゲットにでき、プロバイダーは初回使用時に遅延生成・キャッシュされます。
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "vllm",
|
||||
"model": "your-model-name",
|
||||
"model_fallbacks": ["openai/gpt-4o", "anthropic/claude-sonnet-4-5-20250929"]
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"vllm": {
|
||||
"api_key": "your-vllm-key",
|
||||
"api_base": "https://api.example.com/v1"
|
||||
},
|
||||
"openai": {
|
||||
"auth_method": "oauth"
|
||||
},
|
||||
"anthropic": {
|
||||
"api_key": "sk-ant-xxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
フォーマットは `provider/model`。`/` がない場合はプライマリプロバイダーが使われます(同一プロバイダー内フォールバック)。失敗したプロバイダーはクールダウン期間に入り、連続タイムアウトを防ぎます。
|
||||
|
||||
### プロバイダー
|
||||
|
||||
> [!NOTE]
|
||||
|
|
@ -1045,32 +1008,8 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
| `picoclaw agent -m "..."` | エージェントとチャット |
|
||||
| `picoclaw agent` | インタラクティブチャットモード |
|
||||
| `picoclaw gateway` | ゲートウェイを起動 |
|
||||
| `picoclaw gateway --stats` | 使用量統計を有効にして起動 |
|
||||
| `picoclaw status` | ステータスを表示 |
|
||||
|
||||
### チャットコマンド
|
||||
|
||||
以下のスラッシュコマンドはチャットチャネル(Telegram、Discord 等)で送信でき、LLM トークンを消費せず即座に応答します:
|
||||
|
||||
| コマンド | 説明 |
|
||||
|---------|------|
|
||||
| `/help` | 利用可能なコマンドを表示 |
|
||||
| `/session` | トークン使用量統計を表示(`--stats` が必要) |
|
||||
| `/session reset` | 使用量統計をリセット |
|
||||
| `/skills` | 利用可能なスキルを一覧表示 |
|
||||
| `/skill <名前> [メッセージ]` | スキルを呼び出す |
|
||||
| `/show model` | 現在のモデルを表示 |
|
||||
| `/list channels` | 有効なチャネルを一覧表示 |
|
||||
| `/plan` | 現在の計画の進捗を表示 |
|
||||
| `/plan <タスク>` | インタビュー形式で新しい計画を開始 |
|
||||
| `/plan clear` | 現在の計画を破棄 |
|
||||
|
||||
> **注意**: `/session` はゲートウェイを `--stats` フラグ付きで起動する必要があります。フラグなしではオーバーヘッドゼロで統計追跡は無効です。
|
||||
>
|
||||
> **注意**: `/skills` は LLM コストゼロで即座に応答します。`/skill <名前>` はそのターンのみ SKILL.md をコンテキストに注入し、ターン完了後はスキル名のみ履歴に残してコンテキストウィンドウを節約します。
|
||||
>
|
||||
> **注意**: `/plan` は自動フェーズ進行付きの構造化タスク計画を実現します。AIがインタビューで要件を確認し、フェーズに分割して作業を進め、MEMORY.mdで進捗を管理します。
|
||||
|
||||
## 🤝 コントリビュート&ロードマップ
|
||||
|
||||
PR 歓迎!コードベースは意図的に小さく読みやすくしています。🤗
|
||||
|
|
|
|||
169
README.md
169
README.md
|
|
@ -295,6 +295,8 @@ That's it! You have a working AI assistant in 2 minutes.
|
|||
|
||||
Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or WeCom
|
||||
|
||||
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
|
||||
|
||||
| Channel | Setup |
|
||||
| ------------ | ---------------------------------- |
|
||||
| **Telegram** | Easy (just a token) |
|
||||
|
|
@ -336,69 +338,6 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We
|
|||
picoclaw gateway
|
||||
```
|
||||
|
||||
#### Mini App (Dashboard)
|
||||
|
||||
PicoClaw includes a Telegram Mini App that provides a GUI dashboard directly inside Telegram. It shows plan progress, available skills, active sessions, session stats, and lets you send commands without typing.
|
||||
|
||||
**How it works:**
|
||||
|
||||
When Telegram is enabled, PicoClaw automatically registers a "Dashboard" menu button in the chat. Tapping it opens the Mini App inside Telegram's WebView.
|
||||
|
||||
| Tab | Description |
|
||||
|-----|-------------|
|
||||
| **Plan** | View plan phases/steps as a checklist, tap to mark done, start new plans |
|
||||
| **Skills** | Browse and invoke skills with a message input |
|
||||
| **Session** | View active sessions and token usage stats (requires `--stats` flag for stats) |
|
||||
| **Config** | Quick command buttons, custom command input, and **Log Viewer** |
|
||||
|
||||
**Setup — Tailscale (recommended for self-hosting):**
|
||||
|
||||
Telegram requires HTTPS for Mini Apps. The easiest way is to use [Tailscale](https://tailscale.com/) which provides automatic TLS certificates via MagicDNS.
|
||||
|
||||
1. Install Tailscale on both your server and phone
|
||||
2. Allow cert provisioning (run once on the server):
|
||||
```bash
|
||||
sudo tailscale set --operator=$USER
|
||||
```
|
||||
3. Start the gateway — PicoClaw auto-detects the Tailscale hostname and fetches a TLS certificate:
|
||||
```bash
|
||||
picoclaw gateway --stats
|
||||
```
|
||||
You should see:
|
||||
```
|
||||
✓ Mini App registered at https://<machine>.<tailnet>.ts.net:18790/miniapp
|
||||
```
|
||||
|
||||
> Your phone must also be connected to the same Tailnet to access the Mini App.
|
||||
|
||||
**Setup — Custom URL:**
|
||||
|
||||
If you already have an HTTPS endpoint (e.g., reverse proxy, Cloudflare Tunnel), set `web_app_url` manually:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"web_app_url": "https://your-domain.com/miniapp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `web_app_url` is set, PicoClaw skips Tailscale auto-detection and serves the Mini App over HTTP (your reverse proxy handles TLS).
|
||||
|
||||
**Log Viewer (Config tab):**
|
||||
|
||||
The Config tab includes a real-time log viewer that streams logs via WebSocket. It supports three log streams:
|
||||
|
||||
- **picoclaw** — agent, tools, and channel activity logs
|
||||
- **telego** — Telegram library internal logs
|
||||
- **dev-console** — JavaScript console output from Dev Preview iframes
|
||||
|
||||
Features: component filter chips, auto-reconnect, and **Save Snapshot** to download a `.tar.gz` archive of the current log buffer (auto-deleted after 14 days). When `allow_from` is configured, the log viewer is restricted to allowed users only.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
|
@ -427,8 +366,7 @@ Features: component filter chips, auto-reconnect, and **Save Snapshot** to downl
|
|||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"mention_only": false
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -441,9 +379,31 @@ Features: component filter chips, auto-reconnect, and **Save Snapshot** to downl
|
|||
* Bot Permissions: `Send Messages`, `Read Message History`
|
||||
* Open the generated invite URL and add the bot to your server
|
||||
|
||||
**Optional: Mention-only mode**
|
||||
**Optional: Group trigger mode**
|
||||
|
||||
Set `"mention_only": true` to make the bot respond only when @-mentioned. Useful for shared servers where you want the bot to respond only when explicitly called.
|
||||
By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"discord": {
|
||||
"group_trigger": { "mention_only": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can also trigger by keyword prefixes (e.g. `!bot`):
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"discord": {
|
||||
"group_trigger": { "prefixes": ["!bot"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**6. Run**
|
||||
|
||||
|
|
@ -564,8 +524,6 @@ picoclaw gateway
|
|||
"enabled": true,
|
||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18791,
|
||||
"webhook_path": "/webhook/line",
|
||||
"allow_from": []
|
||||
}
|
||||
|
|
@ -573,13 +531,15 @@ picoclaw gateway
|
|||
}
|
||||
```
|
||||
|
||||
> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
|
||||
|
||||
**3. Set up Webhook URL**
|
||||
|
||||
LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:
|
||||
|
||||
```bash
|
||||
# Example with ngrok
|
||||
ngrok http 18791
|
||||
# Example with ngrok (gateway default port is 18790)
|
||||
ngrok http 18790
|
||||
```
|
||||
|
||||
Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.
|
||||
|
|
@ -592,8 +552,6 @@ picoclaw gateway
|
|||
|
||||
> In group chats, the bot responds only when @mentioned. Replies quote the original message.
|
||||
|
||||
> **Docker Compose**: Add `ports: ["18791:18791"]` to the `picoclaw-gateway` service to expose the webhook port.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
|
@ -623,8 +581,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
|||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18793,
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"allow_from": []
|
||||
}
|
||||
|
|
@ -632,6 +588,8 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
|||
}
|
||||
```
|
||||
|
||||
> WeCom webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
|
||||
|
||||
**Quick Setup - WeCom App:**
|
||||
|
||||
**1. Create an app**
|
||||
|
|
@ -639,10 +597,11 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
|||
* Go to WeCom Admin Console → App Management → Create App
|
||||
* Copy **AgentId** and **Secret**
|
||||
* Go to "My Company" page, copy **CorpID**
|
||||
|
||||
**2. Configure receive message**
|
||||
|
||||
* In App details, click "Receive Message" → "Set API"
|
||||
* Set URL to `http://your-server:18792/webhook/wecom-app`
|
||||
* Set URL to `http://your-server:18790/webhook/wecom-app`
|
||||
* Generate **Token** and **EncodingAESKey**
|
||||
|
||||
**3. Configure**
|
||||
|
|
@ -657,8 +616,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
|||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18792,
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": []
|
||||
}
|
||||
|
|
@ -672,7 +629,7 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
|||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **Note**: WeCom App requires opening port 18792 for webhook callbacks. Use a reverse proxy for HTTPS.
|
||||
> **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS.
|
||||
|
||||
</details>
|
||||
|
||||
|
|
@ -1099,36 +1056,6 @@ PicoClaw routes providers by protocol family:
|
|||
|
||||
This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`).
|
||||
|
||||
### Model Fallbacks
|
||||
|
||||
`model_fallbacks` defines a fallback chain that activates when the primary model returns a retriable error (rate limit, server error, etc.). Fallbacks can target **different providers** — the provider is lazily created and cached on first use.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "vllm",
|
||||
"model": "your-model-name",
|
||||
"model_fallbacks": ["openai/gpt-4o", "anthropic/claude-sonnet-4-5-20250929"]
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"vllm": {
|
||||
"api_key": "your-vllm-key",
|
||||
"api_base": "https://api.example.com/v1"
|
||||
},
|
||||
"openai": {
|
||||
"auth_method": "oauth"
|
||||
},
|
||||
"anthropic": {
|
||||
"api_key": "sk-ant-xxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The format is `provider/model`. If no `/` is present, the primary provider is assumed (same-provider fallback). Failed providers enter a cooldown period to avoid repeated timeouts.
|
||||
|
||||
<details>
|
||||
<summary><b>Zhipu</b></summary>
|
||||
|
||||
|
|
@ -1250,34 +1177,10 @@ picoclaw agent -m "Hello"
|
|||
| `picoclaw agent -m "..."` | Chat with the agent |
|
||||
| `picoclaw agent` | Interactive chat mode |
|
||||
| `picoclaw gateway` | Start the gateway |
|
||||
| `picoclaw gateway --stats`| Start with usage tracking |
|
||||
| `picoclaw status` | Show status |
|
||||
| `picoclaw cron list` | List all scheduled jobs |
|
||||
| `picoclaw cron add ...` | Add a scheduled job |
|
||||
|
||||
### Chat Commands
|
||||
|
||||
These slash commands can be sent in any chat channel (Telegram, Discord, etc.) and return instantly without consuming LLM tokens:
|
||||
|
||||
| Command | Description |
|
||||
| ----------------- | ------------------------------------------------ |
|
||||
| `/help` | Show available commands |
|
||||
| `/session` | Show token usage statistics (requires `--stats`) |
|
||||
| `/session reset` | Reset usage statistics |
|
||||
| `/skills` | List available skills |
|
||||
| `/skill <name> [message]` | Invoke a skill with optional message |
|
||||
| `/show model` | Show current model |
|
||||
| `/list channels` | List enabled channels |
|
||||
| `/plan` | Show current plan progress |
|
||||
| `/plan <task>` | Start a new plan with interview flow |
|
||||
| `/plan clear` | Discard the current plan |
|
||||
|
||||
> **Note**: `/session` requires the gateway to be started with `--stats` flag. Without it, stats tracking is disabled for zero overhead.
|
||||
>
|
||||
> **Note**: `/skills` returns instantly without LLM cost. `/skill <name>` injects the skill's SKILL.md into the conversation context for the current turn, then only the skill name is kept in history to save the context window.
|
||||
>
|
||||
> **Note**: `/plan` enables structured task planning with automatic phase advancement. The AI interviews you to understand requirements, organizes work into phases, and tracks progress through MEMORY.md.
|
||||
|
||||
### Scheduled Tasks / Reminders
|
||||
|
||||
PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool:
|
||||
|
|
|
|||
|
|
@ -450,8 +450,6 @@ picoclaw gateway
|
|||
"enabled": true,
|
||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18791,
|
||||
"webhook_path": "/webhook/line",
|
||||
"allow_from": []
|
||||
}
|
||||
|
|
@ -465,11 +463,13 @@ O LINE requer HTTPS para webhooks. Use um reverse proxy ou tunnel:
|
|||
|
||||
```bash
|
||||
# Exemplo com ngrok
|
||||
ngrok http 18791
|
||||
ngrok http 18790
|
||||
```
|
||||
|
||||
Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**.
|
||||
|
||||
> **Nota**: O webhook do LINE é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Use um proxy reverso/HTTPS ou túnel (como ngrok) para expor o Gateway de forma segura quando necessário.
|
||||
|
||||
**4. Executar**
|
||||
|
||||
```bash
|
||||
|
|
@ -478,7 +478,7 @@ picoclaw gateway
|
|||
|
||||
> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original.
|
||||
|
||||
> **Docker Compose**: Adicione `ports: ["18791:18791"]` ao serviço `picoclaw-gateway` para expor a porta do webhook.
|
||||
> **Docker Compose**: Se você usa Docker Compose, exponha o Gateway (padrão 127.0.0.1:18790) se precisar acessar o webhook LINE externamente, por exemplo `ports: ["18790:18790"]`.
|
||||
|
||||
</details>
|
||||
|
||||
|
|
@ -509,8 +509,6 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
|
|||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18793,
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"allow_from": []
|
||||
}
|
||||
|
|
@ -518,6 +516,8 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
|
|||
}
|
||||
```
|
||||
|
||||
> **Nota**: O webhook do WeCom Bot é atendido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Use um proxy reverso/HTTPS ou túnel para expor o Gateway em produção.
|
||||
|
||||
**Configuração Rápida - WeCom App:**
|
||||
|
||||
**1. Criar um aplicativo**
|
||||
|
|
@ -529,7 +529,7 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
|
|||
**2. Configurar recebimento de mensagens**
|
||||
|
||||
* Nos detalhes do aplicativo, clique em "Receber Mensagens" → "Configurar API"
|
||||
* Defina a URL como `http://your-server:18792/webhook/wecom-app`
|
||||
* Defina a URL como `http://your-server:18790/webhook/wecom-app`
|
||||
* Gere o **Token** e o **EncodingAESKey**
|
||||
|
||||
**3. Configurar**
|
||||
|
|
@ -544,8 +544,6 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
|
|||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18792,
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": []
|
||||
}
|
||||
|
|
@ -559,7 +557,7 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
|
|||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **Nota**: O WeCom App requer a abertura da porta 18792 para callbacks de webhook. Use um proxy reverso para HTTPS em produção.
|
||||
> **Nota**: O WeCom App (callbacks de webhook) é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Em produção use um proxy reverso HTTPS para expor a porta do Gateway, ou atualize `PICOCLAW_GATEWAY_HOST` para `0.0.0.0` se necessário.
|
||||
|
||||
</details>
|
||||
|
||||
|
|
@ -1065,34 +1063,10 @@ Para o guia de migração detalhado, consulte [docs/migration/model-list-migrati
|
|||
| `picoclaw agent -m "..."` | Conversar com o agente |
|
||||
| `picoclaw agent` | Modo de chat interativo |
|
||||
| `picoclaw gateway` | Iniciar o gateway (para bots de chat) |
|
||||
| `picoclaw gateway --stats` | Iniciar com rastreamento de uso |
|
||||
| `picoclaw status` | Mostrar status |
|
||||
| `picoclaw cron list` | Listar todas as tarefas agendadas |
|
||||
| `picoclaw cron add ...` | Adicionar uma tarefa agendada |
|
||||
|
||||
### Comandos de Chat
|
||||
|
||||
Estes comandos de barra podem ser enviados em qualquer canal de chat (Telegram, Discord, etc.) e respondem instantaneamente sem consumir tokens LLM:
|
||||
|
||||
| Comando | Descrição |
|
||||
| --- | --- |
|
||||
| `/help` | Mostrar comandos disponíveis |
|
||||
| `/session` | Mostrar estatísticas de uso de tokens (requer `--stats`) |
|
||||
| `/session reset` | Resetar estatísticas de uso |
|
||||
| `/skills` | Listar habilidades disponíveis |
|
||||
| `/skill <nome> [mensagem]` | Invocar uma habilidade |
|
||||
| `/show model` | Mostrar modelo atual |
|
||||
| `/list channels` | Listar canais habilitados |
|
||||
| `/plan` | Mostrar progresso do plano atual |
|
||||
| `/plan <tarefa>` | Iniciar um novo plano com fluxo de entrevista |
|
||||
| `/plan clear` | Descartar o plano atual |
|
||||
|
||||
> **Nota**: `/session` requer que o gateway seja iniciado com a flag `--stats`. Sem ela, o rastreamento de estatísticas é desabilitado para zero overhead.
|
||||
>
|
||||
> **Nota**: `/skills` responde instantaneamente sem custo LLM. `/skill <nome>` injeta o SKILL.md no contexto apenas para o turno atual, depois apenas o nome da habilidade é mantido no histórico para economizar a janela de contexto.
|
||||
>
|
||||
> **Nota**: `/plan` permite planejamento estruturado de tarefas com avanço automático de fases. A IA entrevista você para entender os requisitos, organiza o trabalho em fases e acompanha o progresso via MEMORY.md.
|
||||
|
||||
### Tarefas Agendadas / Lembretes
|
||||
|
||||
O PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`:
|
||||
|
|
|
|||
42
README.vi.md
42
README.vi.md
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
|
||||
|
||||
<h3>Phần cứng $10 · RAM 10MB · Khởi động 1 giây · 皮皮虾,我们走!</h3>
|
||||
<h3>Phần cứng $10 · RAM 10MB · Khởi động 1 giây · Nào, xuất phát!</h3>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
|
|
@ -424,8 +424,6 @@ picoclaw gateway
|
|||
"enabled": true,
|
||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18791,
|
||||
"webhook_path": "/webhook/line",
|
||||
"allow_from": []
|
||||
}
|
||||
|
|
@ -439,7 +437,7 @@ LINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel:
|
|||
|
||||
```bash
|
||||
# Ví dụ với ngrok
|
||||
ngrok http 18791
|
||||
ngrok http 18790
|
||||
```
|
||||
|
||||
Sau đó cài đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**.
|
||||
|
|
@ -452,7 +450,7 @@ picoclaw gateway
|
|||
|
||||
> Trong nhóm chat, bot chỉ phản hồi khi được @mention. Các câu trả lời sẽ trích dẫn tin nhắn gốc.
|
||||
|
||||
> **Docker Compose**: Thêm `ports: ["18791:18791"]` vào service `picoclaw-gateway` để mở port webhook.
|
||||
> **Docker Compose**: Nếu bạn cần mở port webhook cục bộ, hãy thêm một rule chuyển tiếp từ port Gateway (mặc định 18790) tới host. Lưu ý: LINE webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790).
|
||||
|
||||
</details>
|
||||
|
||||
|
|
@ -483,8 +481,6 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
|
|||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18793,
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"allow_from": []
|
||||
}
|
||||
|
|
@ -492,6 +488,8 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
|
|||
}
|
||||
```
|
||||
|
||||
> **Lưu ý:** Các endpoint webhook của WeCom Bot được phục vụ bởi máy chủ Gateway HTTP dùng chung (mặc định 127.0.0.1:18790). Nếu bạn cần truy cập từ bên ngoài, hãy cấu hình reverse proxy hoặc mở cổng Gateway tương ứng.
|
||||
|
||||
**Thiết lập Nhanh - WeCom App:**
|
||||
|
||||
**1. Tạo ứng dụng**
|
||||
|
|
@ -503,7 +501,7 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
|
|||
**2. Cấu hình nhận tin nhắn**
|
||||
|
||||
* Trong chi tiết ứng dụng, nhấp vào "Nhận Tin nhắn" → "Thiết lập API"
|
||||
* Đặt URL thành `http://your-server:18792/webhook/wecom-app`
|
||||
* Đặt URL thành `http://your-server:18790/webhook/wecom-app`
|
||||
* Tạo **Token** và **EncodingAESKey**
|
||||
|
||||
**3. Cấu hình**
|
||||
|
|
@ -518,8 +516,6 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
|
|||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18792,
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": []
|
||||
}
|
||||
|
|
@ -533,7 +529,7 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
|
|||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **Lưu ý**: WeCom App yêu cầu mở cổng 18792 cho callback webhook. Sử dụng proxy ngược cho HTTPS trong môi trường sản xuất.
|
||||
> **Lưu ý**: WeCom App callback webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790). Sử dụng proxy ngược để cung cấp HTTPS trong môi trường production nếu cần.
|
||||
|
||||
</details>
|
||||
|
||||
|
|
@ -1036,34 +1032,10 @@ Xem hướng dẫn chuyển đổi chi tiết tại [docs/migration/model-list-m
|
|||
| `picoclaw agent -m "..."` | Trò chuyện với agent |
|
||||
| `picoclaw agent` | Chế độ chat tương tác |
|
||||
| `picoclaw gateway` | Khởi động gateway (cho bot chat) |
|
||||
| `picoclaw gateway --stats` | Khởi động với theo dõi sử dụng |
|
||||
| `picoclaw status` | Hiển thị trạng thái |
|
||||
| `picoclaw cron list` | Liệt kê tất cả tác vụ định kỳ |
|
||||
| `picoclaw cron add ...` | Thêm tác vụ định kỳ |
|
||||
|
||||
### Lệnh Chat
|
||||
|
||||
Các lệnh gạch chéo sau có thể gửi trong bất kỳ kênh chat nào (Telegram, Discord, v.v.) và phản hồi ngay lập tức mà không tiêu tốn token LLM:
|
||||
|
||||
| Lệnh | Mô tả |
|
||||
| --- | --- |
|
||||
| `/help` | Hiển thị các lệnh khả dụng |
|
||||
| `/session` | Hiển thị thống kê sử dụng token (yêu cầu `--stats`) |
|
||||
| `/session reset` | Đặt lại thống kê sử dụng |
|
||||
| `/skills` | Liệt kê các kỹ năng khả dụng |
|
||||
| `/skill <tên> [tin nhắn]` | Gọi một kỹ năng |
|
||||
| `/show model` | Hiển thị mô hình hiện tại |
|
||||
| `/list channels` | Liệt kê các kênh đã bật |
|
||||
| `/plan` | Hiển thị tiến độ kế hoạch hiện tại |
|
||||
| `/plan <nhiệm vụ>` | Bắt đầu kế hoạch mới với phỏng vấn |
|
||||
| `/plan clear` | Hủy bỏ kế hoạch hiện tại |
|
||||
|
||||
> **Lưu ý**: `/session` yêu cầu gateway được khởi động với cờ `--stats`. Không có cờ này, theo dõi thống kê bị tắt để không có overhead.
|
||||
>
|
||||
> **Lưu ý**: `/skills` phản hồi ngay lập tức không tốn chi phí LLM. `/skill <tên>` chỉ đưa SKILL.md vào ngữ cảnh cho lượt hiện tại, sau đó chỉ giữ lại tên kỹ năng trong lịch sử để tiết kiệm cửa sổ ngữ cảnh.
|
||||
>
|
||||
> **Lưu ý**: `/plan` cho phép lập kế hoạch nhiệm vụ có cấu trúc với tự động chuyển giai đoạn. AI phỏng vấn bạn để hiểu yêu cầu, tổ chức công việc thành các giai đoạn và theo dõi tiến độ qua MEMORY.md.
|
||||
|
||||
### Tác vụ định kỳ / Nhắc nhở
|
||||
|
||||
PicoClaw hỗ trợ nhắc nhở theo lịch và tác vụ lặp lại thông qua công cụ `cron`:
|
||||
|
|
|
|||
30
README.zh.md
30
README.zh.md
|
|
@ -290,6 +290,8 @@ picoclaw agent -m "2+2 等于几?"
|
|||
|
||||
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
|
||||
|
||||
> **注意**: 所有 Webhook 类渠道(LINE、WeCom 等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。注意:飞书(Feishu)使用 WebSocket/SDK 模式,不通过该共享 HTTP webhook 服务器接收消息。
|
||||
|
||||
### 核心渠道
|
||||
|
||||
| 渠道 | 设置难度 | 特性说明 | 文档链接 |
|
||||
|
|
@ -305,8 +307,6 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
|||
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
||||
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
||||
|
||||
**Mini App(仪表盘):** Telegram Mini App 的 Config 标签页内置实时**日志查看器**,通过 WebSocket 展示三种日志流(picoclaw / telego / dev-console)。支持组件筛选、快照保存(tar.gz,14天自动清理)。配置 `allow_from` 后,日志查看器仅限白名单用户使用。
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||
|
||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||
|
|
@ -741,39 +741,15 @@ picoclaw agent -m "你好"
|
|||
## CLI 命令行参考
|
||||
|
||||
| 命令 | 描述 |
|
||||
| --- | --- |
|
||||
| ------------------------- | ------------------ |
|
||||
| `picoclaw onboard` | 初始化配置和工作区 |
|
||||
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
||||
| `picoclaw agent` | 交互式聊天模式 |
|
||||
| `picoclaw gateway` | 启动网关 (Gateway) |
|
||||
| `picoclaw gateway --stats` | 启用使用量统计 |
|
||||
| `picoclaw status` | 显示状态 |
|
||||
| `picoclaw cron list` | 列出所有定时任务 |
|
||||
| `picoclaw cron add ...` | 添加定时任务 |
|
||||
|
||||
### 聊天命令
|
||||
|
||||
以下斜杠命令可在任何聊天频道(Telegram、Discord 等)中发送,即时响应且不消耗 LLM Token:
|
||||
|
||||
| 命令 | 描述 |
|
||||
| --- | --- |
|
||||
| `/help` | 显示可用命令 |
|
||||
| `/session` | 显示 Token 使用统计(需要 `--stats`) |
|
||||
| `/session reset` | 重置使用统计 |
|
||||
| `/skills` | 列出可用技能 |
|
||||
| `/skill <名称> [消息]` | 调用技能 |
|
||||
| `/show model` | 显示当前模型 |
|
||||
| `/list channels` | 列出已启用的频道 |
|
||||
| `/plan` | 显示当前计划进度 |
|
||||
| `/plan <任务>` | 以访谈方式启动新计划 |
|
||||
| `/plan clear` | 丢弃当前计划 |
|
||||
|
||||
> **注意**: `/session` 需要使用 `--stats` 标志启动网关。未启用时统计跟踪被禁用,零开销。
|
||||
>
|
||||
> **注意**: `/skills` 即时响应,不消耗 LLM Token。`/skill <名称>` 仅在当前轮次将 SKILL.md 注入上下文,轮次结束后仅保留技能名称以节省上下文窗口。
|
||||
>
|
||||
> **注意**: `/plan` 实现带有自动阶段推进的结构化任务规划。AI通过访谈了解需求,将工作组织成多个阶段,并通过 MEMORY.md 跟踪进度。
|
||||
|
||||
### 定时任务 / 提醒 (Scheduled Tasks)
|
||||
|
||||
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 366 KiB After Width: | Height: | Size: 140 KiB |
49
cmd/picoclaw-launcher-tui/internal/config/store.go
Normal file
49
cmd/picoclaw-launcher-tui/internal/config/store.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package configstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
configDirName = ".picoclaw"
|
||||
configFileName = "config.json"
|
||||
)
|
||||
|
||||
func ConfigPath() (string, error) {
|
||||
dir, err := ConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, configFileName), nil
|
||||
}
|
||||
|
||||
func ConfigDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, configDirName), nil
|
||||
}
|
||||
|
||||
func Load() (*picoclawconfig.Config, error) {
|
||||
path, err := ConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return picoclawconfig.LoadConfig(path)
|
||||
}
|
||||
|
||||
func Save(cfg *picoclawconfig.Config) error {
|
||||
if cfg == nil {
|
||||
return errors.New("config is nil")
|
||||
}
|
||||
path, err := ConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return picoclawconfig.SaveConfig(path, cfg)
|
||||
}
|
||||
506
cmd/picoclaw-launcher-tui/internal/ui/app.go
Normal file
506
cmd/picoclaw-launcher-tui/internal/ui/app.go
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config"
|
||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
type appState struct {
|
||||
app *tview.Application
|
||||
pages *tview.Pages
|
||||
stack []string
|
||||
config *picoclawconfig.Config
|
||||
configPath string
|
||||
gatewayCmd *exec.Cmd
|
||||
menus map[string]*Menu
|
||||
original []byte
|
||||
hasOriginal bool
|
||||
backupPath string
|
||||
dirty bool
|
||||
logPath string
|
||||
}
|
||||
|
||||
func Run() error {
|
||||
applyStyles()
|
||||
cfg, err := configstore.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := configstore.ConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg == nil {
|
||||
cfg = picoclawconfig.DefaultConfig()
|
||||
}
|
||||
|
||||
originalData, hasOriginal := loadOriginalConfig(path)
|
||||
backupPath := path + ".bak"
|
||||
if hasOriginal {
|
||||
_ = writeBackupConfig(backupPath, originalData)
|
||||
}
|
||||
|
||||
logPath := filepath.Join(filepath.Dir(path), "gateway.log")
|
||||
state := &appState{
|
||||
app: tview.NewApplication(),
|
||||
pages: tview.NewPages(),
|
||||
config: cfg,
|
||||
configPath: path,
|
||||
menus: map[string]*Menu{},
|
||||
original: originalData,
|
||||
hasOriginal: hasOriginal,
|
||||
backupPath: backupPath,
|
||||
logPath: logPath,
|
||||
}
|
||||
|
||||
state.push("main", state.mainMenu())
|
||||
|
||||
root := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
root.AddItem(bannerView(), 6, 0, false)
|
||||
root.AddItem(state.pages, 0, 1, true)
|
||||
|
||||
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *appState) push(name string, primitive tview.Primitive) {
|
||||
s.pages.AddPage(name, primitive, true, true)
|
||||
s.stack = append(s.stack, name)
|
||||
s.pages.SwitchToPage(name)
|
||||
if menu, ok := primitive.(*Menu); ok {
|
||||
s.menus[name] = menu
|
||||
}
|
||||
}
|
||||
|
||||
func (s *appState) pop() {
|
||||
if len(s.stack) == 0 {
|
||||
return
|
||||
}
|
||||
last := s.stack[len(s.stack)-1]
|
||||
s.pages.RemovePage(last)
|
||||
s.stack = s.stack[:len(s.stack)-1]
|
||||
if len(s.stack) == 0 {
|
||||
s.app.Stop()
|
||||
return
|
||||
}
|
||||
current := s.stack[len(s.stack)-1]
|
||||
s.pages.SwitchToPage(current)
|
||||
if menu, ok := s.menus[current]; ok {
|
||||
s.refreshMenu(current, menu)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *appState) mainMenu() tview.Primitive {
|
||||
menu := NewMenu("Config Menu", nil)
|
||||
refreshMainMenu(menu, s)
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
case tcell.KeyEsc:
|
||||
s.requestExit()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'q' {
|
||||
s.requestExit()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
func (s *appState) refreshMenu(name string, menu *Menu) {
|
||||
switch name {
|
||||
case "main":
|
||||
refreshMainMenu(menu, s)
|
||||
case "model":
|
||||
refreshModelMenuFromState(menu, s)
|
||||
case "channel":
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshMainMenuIfPresent(s *appState) {
|
||||
if menu, ok := s.menus["main"]; ok {
|
||||
refreshMainMenu(menu, s)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshMainMenu(menu *Menu, s *appState) {
|
||||
selectedModel := s.selectedModelName()
|
||||
modelReady := selectedModel != ""
|
||||
channelReady := s.hasEnabledChannel()
|
||||
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
|
||||
|
||||
gatewayLabel := "Start Gateway"
|
||||
gatewayDescription := "Launch gateway for channels"
|
||||
if gatewayRunning {
|
||||
gatewayLabel = "Stop Gateway"
|
||||
gatewayDescription = "Gateway running"
|
||||
}
|
||||
|
||||
items := []MenuItem{
|
||||
{
|
||||
Label: rootModelLabel(selectedModel),
|
||||
Description: rootModelDescription(selectedModel),
|
||||
Action: func() {
|
||||
s.push("model", s.modelMenu())
|
||||
},
|
||||
MainColor: func() *tcell.Color {
|
||||
if modelReady {
|
||||
return nil
|
||||
}
|
||||
color := tcell.ColorGray
|
||||
return &color
|
||||
}(),
|
||||
},
|
||||
{
|
||||
Label: rootChannelLabel(channelReady),
|
||||
Description: rootChannelDescription(channelReady),
|
||||
Action: func() {
|
||||
s.push("channel", s.channelMenu())
|
||||
},
|
||||
MainColor: func() *tcell.Color {
|
||||
if channelReady {
|
||||
return nil
|
||||
}
|
||||
color := tcell.ColorGray
|
||||
return &color
|
||||
}(),
|
||||
},
|
||||
{
|
||||
Label: "Start Talk",
|
||||
Description: "Open picoclaw agent in terminal",
|
||||
Action: func() {
|
||||
s.requestStartTalk()
|
||||
},
|
||||
Disabled: !modelReady,
|
||||
},
|
||||
{
|
||||
Label: gatewayLabel,
|
||||
Description: gatewayDescription,
|
||||
Action: func() {
|
||||
if gatewayRunning {
|
||||
s.stopGateway()
|
||||
} else {
|
||||
s.requestStartGateway()
|
||||
}
|
||||
refreshMainMenu(menu, s)
|
||||
},
|
||||
Disabled: !gatewayRunning && (!modelReady || !channelReady),
|
||||
},
|
||||
{
|
||||
Label: "View Gateway Log",
|
||||
Description: "Open gateway.log",
|
||||
Action: func() {
|
||||
s.viewGatewayLog()
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Exit",
|
||||
Description: "Exit the TUI",
|
||||
Action: func() {
|
||||
s.requestExit()
|
||||
},
|
||||
},
|
||||
}
|
||||
menu.applyItems(items)
|
||||
}
|
||||
|
||||
func (s *appState) applyChangesValidated() bool {
|
||||
if err := s.config.ValidateModelList(); err != nil {
|
||||
s.showMessage("Validation failed", err.Error())
|
||||
return false
|
||||
}
|
||||
if err := s.validateAgentModel(); err != nil {
|
||||
s.showMessage("Validation failed", err.Error())
|
||||
return false
|
||||
}
|
||||
if err := configstore.Save(s.config); err != nil {
|
||||
s.showMessage("Save failed", err.Error())
|
||||
return false
|
||||
}
|
||||
if data, err := os.ReadFile(s.configPath); err == nil {
|
||||
s.original = data
|
||||
s.hasOriginal = true
|
||||
_ = writeBackupConfig(s.backupPath, data)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *appState) requestExit() {
|
||||
if s.dirty {
|
||||
s.confirmApplyOrDiscard(func() {
|
||||
s.app.Stop()
|
||||
}, func() {
|
||||
s.discardChanges()
|
||||
s.app.Stop()
|
||||
})
|
||||
return
|
||||
}
|
||||
s.app.Stop()
|
||||
}
|
||||
|
||||
func (s *appState) requestStartTalk() {
|
||||
if s.dirty {
|
||||
s.confirmApplyOrDiscard(func() {
|
||||
s.startTalk()
|
||||
}, func() {
|
||||
s.startTalk()
|
||||
})
|
||||
return
|
||||
}
|
||||
s.startTalk()
|
||||
}
|
||||
|
||||
func (s *appState) requestStartGateway() {
|
||||
if s.dirty {
|
||||
s.confirmApplyOrDiscard(func() {
|
||||
s.startGateway()
|
||||
}, func() {
|
||||
s.startGateway()
|
||||
})
|
||||
return
|
||||
}
|
||||
s.startGateway()
|
||||
}
|
||||
|
||||
func (s *appState) viewGatewayLog() {
|
||||
data, err := os.ReadFile(s.logPath)
|
||||
if err != nil {
|
||||
s.showMessage("Log not found", "gateway.log not found")
|
||||
return
|
||||
}
|
||||
text := tview.NewTextView()
|
||||
text.SetBorder(true).SetTitle("Gateway Log")
|
||||
text.SetText(string(data))
|
||||
text.SetDoneFunc(func(key tcell.Key) {
|
||||
s.pages.RemovePage("log")
|
||||
})
|
||||
text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
s.pages.RemovePage("log")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
s.pages.AddPage("log", text, true, true)
|
||||
}
|
||||
|
||||
func (s *appState) selectedModelName() string {
|
||||
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||
if modelName == "" {
|
||||
return ""
|
||||
}
|
||||
if !s.isActiveModelValid() {
|
||||
return ""
|
||||
}
|
||||
return modelName
|
||||
}
|
||||
|
||||
func rootModelLabel(selected string) string {
|
||||
if selected == "" {
|
||||
return "Model (no model selected)"
|
||||
}
|
||||
return "Model (" + selected + ")"
|
||||
}
|
||||
|
||||
func rootModelDescription(selected string) string {
|
||||
if selected == "" {
|
||||
return "no model selected"
|
||||
}
|
||||
return "selected"
|
||||
}
|
||||
|
||||
func rootChannelLabel(valid bool) string {
|
||||
if !valid {
|
||||
return "Channel (no channel enabled)"
|
||||
}
|
||||
return "Channel"
|
||||
}
|
||||
|
||||
func rootChannelDescription(valid bool) string {
|
||||
if !valid {
|
||||
return "no channel enabled"
|
||||
}
|
||||
return "enabled"
|
||||
}
|
||||
|
||||
func (s *appState) startTalk() {
|
||||
if !s.isActiveModelValid() {
|
||||
s.showMessage("Model required", "Select a valid model before starting talk")
|
||||
return
|
||||
}
|
||||
if !s.applyChangesValidated() {
|
||||
return
|
||||
}
|
||||
s.app.Suspend(func() {
|
||||
cmd := exec.Command("picoclaw", "agent")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
_ = cmd.Run()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *appState) startGateway() {
|
||||
if !s.isActiveModelValid() {
|
||||
s.showMessage("Model required", "Select a valid model before starting gateway")
|
||||
return
|
||||
}
|
||||
if !s.hasEnabledChannel() {
|
||||
s.showMessage("Channel required", "Enable at least one channel before starting gateway")
|
||||
return
|
||||
}
|
||||
if !s.applyChangesValidated() {
|
||||
return
|
||||
}
|
||||
_ = stopGatewayProcess()
|
||||
cmd := exec.Command("picoclaw", "gateway")
|
||||
logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
s.showMessage("Gateway failed", err.Error())
|
||||
return
|
||||
}
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
if err := cmd.Start(); err != nil {
|
||||
s.showMessage("Gateway failed", err.Error())
|
||||
_ = logFile.Close()
|
||||
return
|
||||
}
|
||||
_ = logFile.Close()
|
||||
s.gatewayCmd = cmd
|
||||
}
|
||||
|
||||
func (s *appState) stopGateway() {
|
||||
_ = stopGatewayProcess()
|
||||
if s.gatewayCmd != nil && s.gatewayCmd.Process != nil {
|
||||
_ = s.gatewayCmd.Process.Kill()
|
||||
}
|
||||
s.gatewayCmd = nil
|
||||
}
|
||||
|
||||
func (s *appState) isGatewayRunning() bool {
|
||||
return isGatewayProcessRunning()
|
||||
}
|
||||
|
||||
func (s *appState) validateAgentModel() error {
|
||||
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||
if modelName == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := s.config.GetModelConfig(modelName)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *appState) isActiveModelValid() bool {
|
||||
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||
if modelName == "" {
|
||||
return false
|
||||
}
|
||||
cfg, err := s.config.GetModelConfig(modelName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth"
|
||||
hasModel := strings.TrimSpace(cfg.Model) != ""
|
||||
return hasKey && hasModel
|
||||
}
|
||||
|
||||
func (s *appState) hasEnabledChannel() bool {
|
||||
c := s.config.Channels
|
||||
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
|
||||
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
|
||||
c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
|
||||
}
|
||||
|
||||
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {
|
||||
if s.pages.HasPage("apply") {
|
||||
return
|
||||
}
|
||||
modal := tview.NewModal().
|
||||
SetText("Apply changes or discard before continuing?").
|
||||
AddButtons([]string{"Cancel", "Discard", "Apply"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
s.pages.RemovePage("apply")
|
||||
switch buttonLabel {
|
||||
case "Discard":
|
||||
s.discardChanges()
|
||||
if onDiscard != nil {
|
||||
onDiscard()
|
||||
}
|
||||
case "Apply":
|
||||
if s.applyChangesValidated() {
|
||||
s.dirty = false
|
||||
if onApply != nil {
|
||||
onApply()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
modal.SetBorder(true)
|
||||
s.pages.AddPage("apply", modal, true, true)
|
||||
}
|
||||
|
||||
func (s *appState) discardChanges() {
|
||||
if s.hasOriginal {
|
||||
_ = writeOriginalConfig(s.configPath, s.original)
|
||||
} else {
|
||||
_ = os.Remove(s.configPath)
|
||||
}
|
||||
_ = os.Remove(s.backupPath)
|
||||
if cfg, err := configstore.Load(); err == nil && cfg != nil {
|
||||
s.config = cfg
|
||||
}
|
||||
s.dirty = false
|
||||
refreshMainMenuIfPresent(s)
|
||||
}
|
||||
|
||||
func (s *appState) showMessage(title, message string) {
|
||||
if s.pages.HasPage("message") {
|
||||
return
|
||||
}
|
||||
modal := tview.NewModal().
|
||||
SetText(strings.TrimSpace(message)).
|
||||
AddButtons([]string{"OK"}).
|
||||
SetDoneFunc(func(_ int, _ string) {
|
||||
s.pages.RemovePage("message")
|
||||
})
|
||||
modal.SetTitle(title).SetBorder(true)
|
||||
modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
|
||||
modal.SetTextColor(tview.Styles.PrimaryTextColor)
|
||||
modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255))
|
||||
modal.SetButtonTextColor(tview.Styles.PrimaryTextColor)
|
||||
s.pages.AddPage("message", modal, true, true)
|
||||
}
|
||||
|
||||
func loadOriginalConfig(path string) ([]byte, bool) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
func writeOriginalConfig(path string, data []byte) error {
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
func writeBackupConfig(path string, data []byte) error {
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
574
cmd/picoclaw-launcher-tui/internal/ui/channel.go
Normal file
574
cmd/picoclaw-launcher-tui/internal/ui/channel.go
Normal file
|
|
@ -0,0 +1,574 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func (s *appState) channelMenu() tview.Primitive {
|
||||
items := []MenuItem{
|
||||
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||
channelItem(
|
||||
"Telegram",
|
||||
"Telegram bot settings",
|
||||
s.config.Channels.Telegram.Enabled,
|
||||
func() { s.push("channel-telegram", s.telegramForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"Discord",
|
||||
"Discord bot settings",
|
||||
s.config.Channels.Discord.Enabled,
|
||||
func() { s.push("channel-discord", s.discordForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"QQ",
|
||||
"QQ bot settings",
|
||||
s.config.Channels.QQ.Enabled,
|
||||
func() { s.push("channel-qq", s.qqForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"MaixCam",
|
||||
"MaixCam gateway",
|
||||
s.config.Channels.MaixCam.Enabled,
|
||||
func() { s.push("channel-maixcam", s.maixcamForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"WhatsApp",
|
||||
"WhatsApp bridge",
|
||||
s.config.Channels.WhatsApp.Enabled,
|
||||
func() { s.push("channel-whatsapp", s.whatsappForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"Feishu",
|
||||
"Feishu bot settings",
|
||||
s.config.Channels.Feishu.Enabled,
|
||||
func() { s.push("channel-feishu", s.feishuForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"DingTalk",
|
||||
"DingTalk bot settings",
|
||||
s.config.Channels.DingTalk.Enabled,
|
||||
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"Slack",
|
||||
"Slack bot settings",
|
||||
s.config.Channels.Slack.Enabled,
|
||||
func() { s.push("channel-slack", s.slackForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"LINE",
|
||||
"LINE bot settings",
|
||||
s.config.Channels.LINE.Enabled,
|
||||
func() { s.push("channel-line", s.lineForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"OneBot",
|
||||
"OneBot settings",
|
||||
s.config.Channels.OneBot.Enabled,
|
||||
func() { s.push("channel-onebot", s.onebotForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"WeCom",
|
||||
"WeCom bot settings",
|
||||
s.config.Channels.WeCom.Enabled,
|
||||
func() { s.push("channel-wecom", s.wecomForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"WeCom App",
|
||||
"WeCom App settings",
|
||||
s.config.Channels.WeComApp.Enabled,
|
||||
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
|
||||
),
|
||||
}
|
||||
|
||||
menu := NewMenu("Channels", items)
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'q' {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
return menu
|
||||
}
|
||||
|
||||
func refreshChannelMenuFromState(menu *Menu, s *appState) {
|
||||
items := []MenuItem{
|
||||
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||
channelItem(
|
||||
"Telegram",
|
||||
"Telegram bot settings",
|
||||
s.config.Channels.Telegram.Enabled,
|
||||
func() { s.push("channel-telegram", s.telegramForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"Discord",
|
||||
"Discord bot settings",
|
||||
s.config.Channels.Discord.Enabled,
|
||||
func() { s.push("channel-discord", s.discordForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"QQ",
|
||||
"QQ bot settings",
|
||||
s.config.Channels.QQ.Enabled,
|
||||
func() { s.push("channel-qq", s.qqForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"MaixCam",
|
||||
"MaixCam gateway",
|
||||
s.config.Channels.MaixCam.Enabled,
|
||||
func() { s.push("channel-maixcam", s.maixcamForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"WhatsApp",
|
||||
"WhatsApp bridge",
|
||||
s.config.Channels.WhatsApp.Enabled,
|
||||
func() { s.push("channel-whatsapp", s.whatsappForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"Feishu",
|
||||
"Feishu bot settings",
|
||||
s.config.Channels.Feishu.Enabled,
|
||||
func() { s.push("channel-feishu", s.feishuForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"DingTalk",
|
||||
"DingTalk bot settings",
|
||||
s.config.Channels.DingTalk.Enabled,
|
||||
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"Slack",
|
||||
"Slack bot settings",
|
||||
s.config.Channels.Slack.Enabled,
|
||||
func() { s.push("channel-slack", s.slackForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"LINE",
|
||||
"LINE bot settings",
|
||||
s.config.Channels.LINE.Enabled,
|
||||
func() { s.push("channel-line", s.lineForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"OneBot",
|
||||
"OneBot settings",
|
||||
s.config.Channels.OneBot.Enabled,
|
||||
func() { s.push("channel-onebot", s.onebotForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"WeCom",
|
||||
"WeCom bot settings",
|
||||
s.config.Channels.WeCom.Enabled,
|
||||
func() { s.push("channel-wecom", s.wecomForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"WeCom App",
|
||||
"WeCom App settings",
|
||||
s.config.Channels.WeComApp.Enabled,
|
||||
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
|
||||
),
|
||||
}
|
||||
menu.applyItems(items)
|
||||
}
|
||||
|
||||
func (s *appState) telegramForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Telegram
|
||||
form := baseChannelForm("Telegram", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||
cfg.Token = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) {
|
||||
cfg.Proxy = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) discordForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Discord
|
||||
form := baseChannelForm("Discord", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||
cfg.Token = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) {
|
||||
cfg.MentionOnly = checked
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) qqForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.QQ
|
||||
form := baseChannelForm("QQ", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
||||
cfg.AppID = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
||||
cfg.AppSecret = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) maixcamForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.MaixCam
|
||||
form := baseChannelForm("MaixCam", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Host", cfg.Host, 64, nil, func(text string) {
|
||||
cfg.Host = strings.TrimSpace(text)
|
||||
})
|
||||
addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value })
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) whatsappForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.WhatsApp
|
||||
form := baseChannelForm("WhatsApp", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) {
|
||||
cfg.BridgeURL = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) feishuForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Feishu
|
||||
form := baseChannelForm("Feishu", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
||||
cfg.AppID = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
||||
cfg.AppSecret = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) {
|
||||
cfg.EncryptKey = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) {
|
||||
cfg.VerificationToken = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) dingtalkForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.DingTalk
|
||||
form := baseChannelForm("DingTalk", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) {
|
||||
cfg.ClientID = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) {
|
||||
cfg.ClientSecret = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) slackForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Slack
|
||||
form := baseChannelForm("Slack", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) {
|
||||
cfg.BotToken = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) {
|
||||
cfg.AppToken = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) lineForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.LINE
|
||||
form := baseChannelForm("LINE", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) {
|
||||
cfg.ChannelSecret = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) {
|
||||
cfg.ChannelAccessToken = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||
cfg.WebhookHost = strings.TrimSpace(text)
|
||||
})
|
||||
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||
cfg.WebhookPath = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) onebotForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.OneBot
|
||||
form := baseChannelForm("OneBot", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) {
|
||||
cfg.WSUrl = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
|
||||
cfg.AccessToken = strings.TrimSpace(text)
|
||||
})
|
||||
addIntField(
|
||||
form,
|
||||
"Reconnect Interval",
|
||||
cfg.ReconnectInterval,
|
||||
func(value int) { cfg.ReconnectInterval = value },
|
||||
)
|
||||
form.AddInputField(
|
||||
"Group Trigger Prefix",
|
||||
strings.Join(cfg.GroupTriggerPrefix, ","),
|
||||
128,
|
||||
nil,
|
||||
func(text string) {
|
||||
cfg.GroupTriggerPrefix = splitCSV(text)
|
||||
},
|
||||
)
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) wecomForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.WeCom
|
||||
form := baseChannelForm("WeCom", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||
cfg.Token = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
||||
cfg.EncodingAESKey = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) {
|
||||
cfg.WebhookURL = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||
cfg.WebhookHost = strings.TrimSpace(text)
|
||||
})
|
||||
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||
cfg.WebhookPath = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
addIntField(
|
||||
form,
|
||||
"Reply Timeout",
|
||||
cfg.ReplyTimeout,
|
||||
func(value int) { cfg.ReplyTimeout = value },
|
||||
)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) wecomAppForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.WeComApp
|
||||
form := baseChannelForm("WeCom App", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) {
|
||||
cfg.CorpID = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) {
|
||||
cfg.CorpSecret = strings.TrimSpace(text)
|
||||
})
|
||||
addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value })
|
||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||
cfg.Token = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
||||
cfg.EncodingAESKey = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||
cfg.WebhookHost = strings.TrimSpace(text)
|
||||
})
|
||||
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||
cfg.WebhookPath = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
addIntField(
|
||||
form,
|
||||
"Reply Timeout",
|
||||
cfg.ReplyTimeout,
|
||||
func(value int) { cfg.ReplyTimeout = value },
|
||||
)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form {
|
||||
form := tview.NewForm()
|
||||
form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title))
|
||||
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
||||
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
||||
form.AddCheckbox("Enabled", enabled, func(checked bool) {
|
||||
onEnabled(checked)
|
||||
})
|
||||
return form
|
||||
}
|
||||
|
||||
func wrapWithBack(form *tview.Form, s *appState) tview.Primitive {
|
||||
form.AddButton("Back", func() {
|
||||
s.pop()
|
||||
})
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
return form
|
||||
}
|
||||
|
||||
func splitCSV(input string) picoclawconfig.FlexibleStringSlice {
|
||||
parts := strings.Split(strings.TrimSpace(input), ",")
|
||||
cleaned := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
value := strings.TrimSpace(part)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, value)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func addIntField(form *tview.Form, label string, value int, onChange func(int)) {
|
||||
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||
var parsed int
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||
onChange(parsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) {
|
||||
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||
var parsed int64
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||
onChange(parsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func channelItem(label, description string, enabled bool, action MenuAction) MenuItem {
|
||||
item := MenuItem{
|
||||
Label: label,
|
||||
Description: description,
|
||||
Action: action,
|
||||
}
|
||||
if !enabled {
|
||||
color := tcell.ColorGray
|
||||
item.MainColor = &color
|
||||
}
|
||||
return item
|
||||
}
|
||||
16
cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go
Normal file
16
cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package ui
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func isGatewayProcessRunning() bool {
|
||||
cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
func stopGatewayProcess() error {
|
||||
cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
|
||||
return cmd.Run()
|
||||
}
|
||||
16
cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go
Normal file
16
cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package ui
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func isGatewayProcessRunning() bool {
|
||||
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
func stopGatewayProcess() error {
|
||||
cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe")
|
||||
return cmd.Run()
|
||||
}
|
||||
72
cmd/picoclaw-launcher-tui/internal/ui/menu.go
Normal file
72
cmd/picoclaw-launcher-tui/internal/ui/menu.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
type MenuAction func()
|
||||
|
||||
type MenuItem struct {
|
||||
Label string
|
||||
Description string
|
||||
Action MenuAction
|
||||
Disabled bool
|
||||
MainColor *tcell.Color
|
||||
DescColor *tcell.Color
|
||||
}
|
||||
|
||||
type Menu struct {
|
||||
*tview.Table
|
||||
items []MenuItem
|
||||
}
|
||||
|
||||
func NewMenu(title string, items []MenuItem) *Menu {
|
||||
table := tview.NewTable().SetSelectable(true, false)
|
||||
table.SetBorder(true).SetTitle(title)
|
||||
table.SetBorders(false)
|
||||
menu := &Menu{Table: table, items: items}
|
||||
menu.applyItems(items)
|
||||
menu.SetSelectedFunc(func(row, _ int) {
|
||||
if row < 0 || row >= len(menu.items) {
|
||||
return
|
||||
}
|
||||
item := menu.items[row]
|
||||
if item.Disabled || item.Action == nil {
|
||||
return
|
||||
}
|
||||
item.Action()
|
||||
})
|
||||
menu.SetSelectedStyle(
|
||||
tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor).
|
||||
Background(tcell.NewRGBColor(189, 147, 249)),
|
||||
)
|
||||
return menu
|
||||
}
|
||||
|
||||
func (m *Menu) applyItems(items []MenuItem) {
|
||||
m.items = items
|
||||
m.Clear()
|
||||
for row, item := range items {
|
||||
label := item.Label
|
||||
if item.Disabled && label != "" {
|
||||
label = label + " (disabled)"
|
||||
}
|
||||
left := tview.NewTableCell(label)
|
||||
right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight)
|
||||
if item.MainColor != nil {
|
||||
left.SetTextColor(*item.MainColor)
|
||||
}
|
||||
if item.DescColor != nil {
|
||||
right.SetTextColor(*item.DescColor)
|
||||
} else {
|
||||
right.SetTextColor(tview.Styles.TertiaryTextColor)
|
||||
}
|
||||
if item.Disabled {
|
||||
left.SetTextColor(tcell.ColorGray)
|
||||
right.SetTextColor(tcell.ColorGray)
|
||||
}
|
||||
m.SetCell(row, 0, left)
|
||||
m.SetCell(row, 1, right)
|
||||
}
|
||||
}
|
||||
343
cmd/picoclaw-launcher-tui/internal/ui/model.go
Normal file
343
cmd/picoclaw-launcher-tui/internal/ui/model.go
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func (s *appState) modelMenu() tview.Primitive {
|
||||
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
||||
items = append(items,
|
||||
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||
MenuItem{
|
||||
Label: "Add model",
|
||||
Description: "Append a new model entry",
|
||||
Action: func() {
|
||||
s.addModel(
|
||||
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
||||
)
|
||||
s.push(
|
||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||
s.modelForm(len(s.config.ModelList)-1),
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||
for i := range s.config.ModelList {
|
||||
index := i
|
||||
model := s.config.ModelList[i]
|
||||
isValid := isModelValid(model)
|
||||
desc := model.APIBase
|
||||
if desc == "" {
|
||||
desc = model.AuthMethod
|
||||
}
|
||||
if desc == "" {
|
||||
desc = "api_key required"
|
||||
}
|
||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||
if model.ModelName == currentModel && currentModel != "" {
|
||||
label = "* " + label
|
||||
}
|
||||
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||
items = append(items, MenuItem{
|
||||
Label: label,
|
||||
Description: desc,
|
||||
MainColor: modelStatusColor(isValid, isSelected),
|
||||
Action: func() {
|
||||
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
menu := NewMenu("Models", items)
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'q' {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == ' ' {
|
||||
row, _ := menu.GetSelection()
|
||||
if row > 0 && row <= len(s.config.ModelList) {
|
||||
model := s.config.ModelList[row-1]
|
||||
if !isModelValid(model) {
|
||||
s.showMessage(
|
||||
"Invalid model",
|
||||
"Select a model with api_key or oauth auth_method",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
s.config.Agents.Defaults.Model = model.ModelName
|
||||
s.dirty = true
|
||||
refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList)
|
||||
refreshMainMenuIfPresent(s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
return menu
|
||||
}
|
||||
|
||||
func (s *appState) modelForm(index int) tview.Primitive {
|
||||
model := &s.config.ModelList[index]
|
||||
form := tview.NewForm()
|
||||
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
||||
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
||||
|
||||
addInput(form, "Model Name", model.ModelName, func(value string) {
|
||||
model.ModelName = value
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["model"]; ok {
|
||||
refreshModelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
addInput(form, "Model", model.Model, func(value string) {
|
||||
model.Model = value
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["model"]; ok {
|
||||
refreshModelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
addInput(form, "API Base", model.APIBase, func(value string) {
|
||||
model.APIBase = value
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["model"]; ok {
|
||||
refreshModelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
addInput(form, "API Key", model.APIKey, func(value string) {
|
||||
model.APIKey = value
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["model"]; ok {
|
||||
refreshModelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
addInput(form, "Proxy", model.Proxy, func(value string) {
|
||||
model.Proxy = value
|
||||
})
|
||||
addInput(form, "Auth Method", model.AuthMethod, func(value string) {
|
||||
model.AuthMethod = value
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["model"]; ok {
|
||||
refreshModelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
addInput(form, "Connect Mode", model.ConnectMode, func(value string) {
|
||||
model.ConnectMode = value
|
||||
})
|
||||
addInput(form, "Workspace", model.Workspace, func(value string) {
|
||||
model.Workspace = value
|
||||
})
|
||||
addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) {
|
||||
model.MaxTokensField = value
|
||||
})
|
||||
addIntInput(form, "RPM", model.RPM, func(value int) {
|
||||
model.RPM = value
|
||||
})
|
||||
addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) {
|
||||
model.RequestTimeout = value
|
||||
})
|
||||
|
||||
form.AddButton("Delete", func() {
|
||||
s.deleteModel(index)
|
||||
})
|
||||
form.AddButton("Test", func() {
|
||||
s.testModel(model)
|
||||
})
|
||||
form.AddButton("Back", func() {
|
||||
s.pop()
|
||||
})
|
||||
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
return form
|
||||
}
|
||||
|
||||
func addInput(form *tview.Form, label, value string, onChange func(string)) {
|
||||
form.AddInputField(label, value, 128, nil, func(text string) {
|
||||
onChange(strings.TrimSpace(text))
|
||||
})
|
||||
}
|
||||
|
||||
func addIntInput(form *tview.Form, label string, value int, onChange func(int)) {
|
||||
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||
var parsed int
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||
onChange(parsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *appState) addModel(model picoclawconfig.ModelConfig) {
|
||||
s.config.ModelList = append(s.config.ModelList, model)
|
||||
}
|
||||
|
||||
func (s *appState) deleteModel(index int) {
|
||||
if index < 0 || index >= len(s.config.ModelList) {
|
||||
return
|
||||
}
|
||||
s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...)
|
||||
s.pop()
|
||||
}
|
||||
|
||||
func modelStatusColor(valid bool, selected bool) *tcell.Color {
|
||||
if valid {
|
||||
color := tview.Styles.PrimaryTextColor
|
||||
return &color
|
||||
}
|
||||
color := tcell.ColorGray
|
||||
return &color
|
||||
}
|
||||
|
||||
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
||||
for i, model := range models {
|
||||
row := i + 1
|
||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||
isValid := isModelValid(model)
|
||||
if model.ModelName == currentModel && currentModel != "" {
|
||||
label = "* " + label
|
||||
}
|
||||
cell := menu.GetCell(row, 0)
|
||||
if cell != nil {
|
||||
cell.SetText(label)
|
||||
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||
color := modelStatusColor(isValid, isSelected)
|
||||
if color != nil {
|
||||
cell.SetTextColor(*color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
||||
items = append(items,
|
||||
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||
MenuItem{
|
||||
Label: "Add model",
|
||||
Description: "Append a new model entry",
|
||||
Action: func() {
|
||||
s.addModel(
|
||||
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
||||
)
|
||||
s.push(
|
||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||
s.modelForm(len(s.config.ModelList)-1),
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||
for i := range s.config.ModelList {
|
||||
index := i
|
||||
model := s.config.ModelList[i]
|
||||
isValid := isModelValid(model)
|
||||
desc := model.APIBase
|
||||
if desc == "" {
|
||||
desc = model.AuthMethod
|
||||
}
|
||||
if desc == "" {
|
||||
desc = "api_key required"
|
||||
}
|
||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||
if model.ModelName == currentModel && currentModel != "" {
|
||||
label = "* " + label
|
||||
}
|
||||
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||
items = append(items, MenuItem{
|
||||
Label: label,
|
||||
Description: desc,
|
||||
MainColor: modelStatusColor(isValid, isSelected),
|
||||
Action: func() {
|
||||
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
|
||||
},
|
||||
})
|
||||
}
|
||||
menu.applyItems(items)
|
||||
}
|
||||
|
||||
func isModelValid(model picoclawconfig.ModelConfig) bool {
|
||||
hasKey := strings.TrimSpace(model.APIKey) != "" ||
|
||||
strings.TrimSpace(model.AuthMethod) == "oauth"
|
||||
hasModel := strings.TrimSpace(model.Model) != ""
|
||||
return hasKey && hasModel
|
||||
}
|
||||
|
||||
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
||||
if model == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(model.APIKey) == "" {
|
||||
s.showMessage("Missing API Key", "Set api_key before testing")
|
||||
return
|
||||
}
|
||||
base := strings.TrimSpace(model.APIBase)
|
||||
if base == "" {
|
||||
s.showMessage("Missing API Base", "Set api_base before testing")
|
||||
return
|
||||
}
|
||||
modelID := strings.TrimSpace(model.Model)
|
||||
if modelID == "" {
|
||||
s.showMessage("Missing Model", "Set model before testing")
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(modelID, "openai/") {
|
||||
s.showMessage("Unsupported model", "Only openai/* models are supported for test")
|
||||
return
|
||||
}
|
||||
modelName := strings.TrimPrefix(modelID, "openai/")
|
||||
endpoint := strings.TrimRight(base, "/") + "/chat/completions"
|
||||
|
||||
payload := fmt.Sprintf(
|
||||
`{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`,
|
||||
modelName,
|
||||
)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload))
|
||||
if err != nil {
|
||||
s.showMessage("Test failed", err.Error())
|
||||
return
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey))
|
||||
|
||||
resp, err := client.Do(request)
|
||||
if err != nil {
|
||||
s.showMessage("Test failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
s.showMessage("Test OK", resp.Status)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
s.showMessage(
|
||||
"Test failed",
|
||||
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
|
||||
)
|
||||
}
|
||||
37
cmd/picoclaw-launcher-tui/internal/ui/style.go
Normal file
37
cmd/picoclaw-launcher-tui/internal/ui/style.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
func applyStyles() {
|
||||
tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22)
|
||||
tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53)
|
||||
tview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32)
|
||||
tview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255)
|
||||
tview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198)
|
||||
tview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253)
|
||||
tview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255)
|
||||
tview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123)
|
||||
tview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253)
|
||||
tview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22)
|
||||
tview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249)
|
||||
}
|
||||
|
||||
func bannerView() *tview.TextView {
|
||||
text := tview.NewTextView()
|
||||
text.SetDynamicColors(true)
|
||||
text.SetTextAlign(tview.AlignCenter)
|
||||
text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
|
||||
text.SetText(
|
||||
"[::b][#84aaff]██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
||||
"[#84aaff]██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
||||
"[#84aaff]██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
|
||||
"[#84aaff]██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
|
||||
"[#84aaff]██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
||||
"[#84aaff]╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝",
|
||||
)
|
||||
text.SetBorder(false)
|
||||
return text
|
||||
}
|
||||
15
cmd/picoclaw-launcher-tui/main.go
Normal file
15
cmd/picoclaw-launcher-tui/main.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := ui.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
290
cmd/picoclaw-launcher/README.md
Normal file
290
cmd/picoclaw-launcher/README.md
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
# PicoClaw Launcher
|
||||
|
||||
> [!WARNING]
|
||||
> This project is a temporary solution and will be refactored in the future to provide a complete web service. Therefore, the APIs in this directory are not stable.
|
||||
|
||||
A standalone launcher for PicoClaw, providing visual JSON editing and OAuth provider authentication management.
|
||||
|
||||
## Features
|
||||
|
||||
- 📝 **Config Editor** — Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor
|
||||
- 🤖 **Model Management** — Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation
|
||||
- 📡 **Channel Configuration** — Form-based settings for 12 channel types (Telegram, Discord, Slack, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links
|
||||
- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth)
|
||||
- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies
|
||||
- 🌍 **i18n** — Chinese/English language switching with browser auto-detection
|
||||
- 🎨 **Theme** — Light / Dark / System theme toggle with localStorage persistence
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build
|
||||
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||
|
||||
# Run with default config path (~/.picoclaw/config.json)
|
||||
./picoclaw-launcher
|
||||
|
||||
# Specify a config file
|
||||
./picoclaw-launcher ./config.json
|
||||
|
||||
# Allow LAN access
|
||||
./picoclaw-launcher -public
|
||||
```
|
||||
|
||||
Open `http://localhost:18800` in your browser.
|
||||
|
||||
## CLI Options
|
||||
|
||||
```
|
||||
Usage: picoclaw-config [options] [config.json]
|
||||
|
||||
Arguments:
|
||||
config.json Path to the configuration file (default: ~/.picoclaw/config.json)
|
||||
|
||||
Options:
|
||||
-public Listen on all interfaces (0.0.0.0), allowing access from other devices
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Base URL: `http://localhost:18800`
|
||||
|
||||
---
|
||||
|
||||
### Static Files
|
||||
|
||||
#### GET /
|
||||
|
||||
Serves the embedded frontend (`index.html`).
|
||||
|
||||
---
|
||||
|
||||
### Config API
|
||||
|
||||
#### GET /api/config
|
||||
|
||||
Reads the current configuration file.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"config": { ... },
|
||||
"path": "/Users/xiao/.picoclaw/config.json"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### PUT /api/config
|
||||
|
||||
Saves the configuration. The request body must be a complete Config JSON object.
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": { "defaults": { "model_name": "gpt-5.2" } },
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
**Error** `400 Bad Request` — Invalid JSON
|
||||
|
||||
---
|
||||
|
||||
### Auth API
|
||||
|
||||
#### GET /api/auth/status
|
||||
|
||||
Returns the authentication status of all providers and any in-progress device code login.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"provider": "openai",
|
||||
"auth_method": "oauth",
|
||||
"status": "active",
|
||||
"account_id": "user-xxx",
|
||||
"expires_at": "2026-03-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"pending_device": {
|
||||
"provider": "openai",
|
||||
"status": "pending",
|
||||
"device_url": "https://auth.openai.com/activate",
|
||||
"user_code": "ABCD-1234"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`status` values: `active` | `expired` | `needs_refresh`
|
||||
|
||||
`pending_device` is only present when a device code login is in progress.
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/auth/login
|
||||
|
||||
Initiates a provider login.
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{ "provider": "openai" }
|
||||
```
|
||||
|
||||
Supported `provider` values: `openai` | `anthropic` | `google-antigravity`
|
||||
|
||||
##### OpenAI (Device Code Flow)
|
||||
|
||||
Returns device code info. The server polls for completion in the background.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pending",
|
||||
"device_url": "https://auth.openai.com/activate",
|
||||
"user_code": "ABCD-1234",
|
||||
"message": "Open the URL and enter the code to authenticate."
|
||||
}
|
||||
```
|
||||
|
||||
The user opens `device_url` in a browser and enters `user_code`. Once authenticated, `GET /api/auth/status` will show `pending_device.status` as `success`.
|
||||
|
||||
##### Anthropic (API Token)
|
||||
|
||||
Requires a `token` field in the request:
|
||||
|
||||
```json
|
||||
{ "provider": "anthropic", "token": "sk-ant-xxx" }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "status": "success", "message": "Anthropic token saved" }
|
||||
```
|
||||
|
||||
##### Google Antigravity (Browser OAuth)
|
||||
|
||||
Returns an authorization URL for the frontend to open in a new tab:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "redirect",
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
|
||||
"message": "Open the URL to authenticate with Google."
|
||||
}
|
||||
```
|
||||
|
||||
After authentication, Google redirects to `GET /auth/callback`, which saves the credentials and redirects back to the picoclaw-config UI.
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/auth/logout
|
||||
|
||||
Logs out from a provider.
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{ "provider": "openai" }
|
||||
```
|
||||
|
||||
Omit or leave `provider` empty to log out from all providers.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /auth/callback
|
||||
|
||||
OAuth browser callback endpoint (used by Google Antigravity). Called by the OAuth provider's redirect — **not invoked directly by the frontend**.
|
||||
|
||||
**Query Parameters:**
|
||||
- `state` — OAuth state for CSRF validation
|
||||
- `code` — Authorization code
|
||||
|
||||
On success, redirects to `/#auth`.
|
||||
|
||||
|
||||
### Process API
|
||||
|
||||
#### GET /api/process/status
|
||||
|
||||
Gets the running status of the `picoclaw gateway` process.
|
||||
|
||||
**Response** `200 OK` (Running)
|
||||
|
||||
```json
|
||||
{
|
||||
"process_status": "running",
|
||||
"status": "ok",
|
||||
"uptime": "1.010814s"
|
||||
}
|
||||
```
|
||||
|
||||
**Response** `200 OK` (Stopped)
|
||||
|
||||
```json
|
||||
{
|
||||
"process_status": "stopped",
|
||||
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/process/start
|
||||
|
||||
Starts the `picoclaw gateway` process in the background.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"pid": 12345
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/process/stop
|
||||
|
||||
Stops the running `picoclaw gateway` process.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go test -v ./cmd/picoclaw-launcher/
|
||||
```
|
||||
287
cmd/picoclaw-launcher/README.zh.md
Normal file
287
cmd/picoclaw-launcher/README.zh.md
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
# PicoClaw Launcher
|
||||
|
||||
> [!WARNING]
|
||||
> 该项目属于临时解决方案,后续会重构并提供完整的 Web 服务,因此该目录下的接口并不稳定。
|
||||
|
||||
PicoClaw 的独立启动器,提供可视化 JSON 配置编辑和 OAuth Provider 认证管理。
|
||||
|
||||
## 功能
|
||||
|
||||
- 📝 **配置编辑** — 侧边栏式设置 UI,支持模型管理、通道配置表单和原始 JSON 编辑器
|
||||
- 🤖 **模型管理** — 模型卡片网格,可用性状态显示(无 API Key 时灰色),主模型选择,增删改查,必填/选填字段分离
|
||||
- 📡 **通道配置** — 12 种通道类型(Telegram、Discord、Slack、企业微信、钉钉、飞书、LINE、WhatsApp、QQ、OneBot、MaixCAM 等)的表单化配置,附带文档链接
|
||||
- 🔐 **Provider 认证** — 支持 OpenAI (Device Code)、Anthropic (API Token)、Google Antigravity (Browser OAuth) 登录
|
||||
- 🌐 **嵌入式前端** — 编译为单一二进制文件,无需额外依赖
|
||||
- 🌍 **国际化** — 中英文切换,首次访问自动检测浏览器语言
|
||||
- 🎨 **主题** — 亮色 / 暗色 / 跟随系统,偏好保存在 localStorage
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 编译
|
||||
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||
|
||||
# 运行(使用默认配置路径 ~/.picoclaw/config.json)
|
||||
./picoclaw-launcher
|
||||
|
||||
# 指定配置文件
|
||||
./picoclaw-launcher ./config.json
|
||||
|
||||
# 允许局域网访问
|
||||
./picoclaw-launcher -public
|
||||
```
|
||||
|
||||
启动后在浏览器中打开 `http://localhost:18800`。
|
||||
|
||||
## 命令行参数
|
||||
|
||||
```
|
||||
Usage: picoclaw-launcher [options] [config.json]
|
||||
|
||||
Arguments:
|
||||
config.json 配置文件路径(默认: ~/.picoclaw/config.json)
|
||||
|
||||
Options:
|
||||
-public 监听所有网络接口(0.0.0.0),允许局域网设备访问
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
Base URL: `http://localhost:18800`
|
||||
|
||||
### 静态文件
|
||||
|
||||
#### GET /
|
||||
|
||||
提供嵌入式前端页面(`index.html`)。
|
||||
|
||||
---
|
||||
|
||||
### Config API
|
||||
|
||||
#### GET /api/config
|
||||
|
||||
读取当前配置文件内容。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"config": { ... },
|
||||
"path": "/Users/xiao/.picoclaw/config.json"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### PUT /api/config
|
||||
|
||||
保存配置。请求体为完整的 Config JSON。
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": { "defaults": { "model_name": "gpt-5.2" } },
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
**Error** `400 Bad Request` — 无效 JSON
|
||||
|
||||
---
|
||||
|
||||
### Auth API
|
||||
|
||||
#### GET /api/auth/status
|
||||
|
||||
获取所有 Provider 的认证状态和进行中的 Device Code 登录信息。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"provider": "openai",
|
||||
"auth_method": "oauth",
|
||||
"status": "active",
|
||||
"account_id": "user-xxx",
|
||||
"expires_at": "2026-03-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"pending_device": {
|
||||
"provider": "openai",
|
||||
"status": "pending",
|
||||
"device_url": "https://auth.openai.com/activate",
|
||||
"user_code": "ABCD-1234"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`status` 可选值: `active` | `expired` | `needs_refresh`
|
||||
|
||||
`pending_device` 仅在有进行中的 Device Code 登录时返回。
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/auth/login
|
||||
|
||||
发起 Provider 登录。
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{ "provider": "openai" }
|
||||
```
|
||||
|
||||
支持的 `provider` 值: `openai` | `anthropic` | `google-antigravity`
|
||||
|
||||
##### OpenAI (Device Code Flow)
|
||||
|
||||
返回 Device Code 信息,后台自动轮询认证结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pending",
|
||||
"device_url": "https://auth.openai.com/activate",
|
||||
"user_code": "ABCD-1234",
|
||||
"message": "Open the URL and enter the code to authenticate."
|
||||
}
|
||||
```
|
||||
|
||||
用户在浏览器中打开 `device_url` 并输入 `user_code`。认证完成后通过 `GET /api/auth/status` 的 `pending_device.status` 变为 `success` 通知前端。
|
||||
|
||||
##### Anthropic (API Token)
|
||||
|
||||
需在请求中附带 token:
|
||||
|
||||
```json
|
||||
{ "provider": "anthropic", "token": "sk-ant-xxx" }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "status": "success", "message": "Anthropic token saved" }
|
||||
```
|
||||
|
||||
##### Google Antigravity (Browser OAuth)
|
||||
|
||||
返回授权 URL,前端打开新标签页:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "redirect",
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
|
||||
"message": "Open the URL to authenticate with Google."
|
||||
}
|
||||
```
|
||||
|
||||
认证完成后 Google 回调至 `GET /auth/callback`,自动保存凭据并重定向回 picoclaw-config 页面。
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/auth/logout
|
||||
|
||||
登出 Provider。
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{ "provider": "openai" }
|
||||
```
|
||||
|
||||
传空字符串或省略 `provider` 则登出所有 Provider。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /auth/callback
|
||||
|
||||
OAuth Browser 回调端点(Google Antigravity 专用),由 OAuth Provider 重定向调用,**非前端直接使用**。
|
||||
|
||||
**Query Parameters:**
|
||||
- `state` — OAuth state 校验
|
||||
- `code` — 授权码
|
||||
|
||||
认证成功后重定向到 `/#auth`。
|
||||
|
||||
### Process API
|
||||
|
||||
#### GET /api/process/status
|
||||
|
||||
获取 `picoclaw gateway` 进程的运行状态。
|
||||
|
||||
**Response** `200 OK` (运行中)
|
||||
|
||||
```json
|
||||
{
|
||||
"process_status": "running",
|
||||
"status": "ok",
|
||||
"uptime": "1.010814s"
|
||||
}
|
||||
```
|
||||
|
||||
**Response** `200 OK` (未运行)
|
||||
|
||||
```json
|
||||
{
|
||||
"process_status": "stopped",
|
||||
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/process/start
|
||||
|
||||
在后台启动 `picoclaw gateway` 进程。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"pid": 12345
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/process/stop
|
||||
|
||||
停止正在运行的 `picoclaw gateway` 进程。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
go test -v ./cmd/picoclaw-launcher/
|
||||
```
|
||||
BIN
cmd/picoclaw-launcher/icon.ico
Normal file
BIN
cmd/picoclaw-launcher/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
147
cmd/picoclaw-launcher/internal/server/auth_config.go
Normal file
147
cmd/picoclaw-launcher/internal/server/auth_config.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// updateConfigAfterLogin updates config.json after a successful provider login.
|
||||
func updateConfigAfterLogin(configPath, provider string, cred *auth.AuthCredential) {
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
log.Printf("Warning: could not load config to update auth_method: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "openai":
|
||||
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||
found := false
|
||||
for i := range cfg.ModelList {
|
||||
if isOpenAIModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = "oauth"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
}
|
||||
cfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||
|
||||
case "anthropic":
|
||||
cfg.Providers.Anthropic.AuthMethod = "token"
|
||||
found := false
|
||||
for i := range cfg.ModelList {
|
||||
if isAnthropicModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = "token"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||
ModelName: "claude-sonnet-4.6",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
AuthMethod: "token",
|
||||
})
|
||||
}
|
||||
cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
||||
|
||||
case "google-antigravity":
|
||||
cfg.Providers.Antigravity.AuthMethod = "oauth"
|
||||
found := false
|
||||
for i := range cfg.ModelList {
|
||||
if isAntigravityModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = "oauth"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gemini-flash",
|
||||
Model: "antigravity/gemini-3-flash",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
}
|
||||
cfg.Agents.Defaults.ModelName = "gemini-flash"
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
log.Printf("Warning: could not update config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// clearAuthMethodInConfig clears auth_method for a specific provider in config.json.
|
||||
func clearAuthMethodInConfig(configPath, provider string) {
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range cfg.ModelList {
|
||||
switch provider {
|
||||
case "openai":
|
||||
if isOpenAIModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "anthropic":
|
||||
if isAnthropicModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "google-antigravity", "antigravity":
|
||||
if isAntigravityModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "openai":
|
||||
cfg.Providers.OpenAI.AuthMethod = ""
|
||||
case "anthropic":
|
||||
cfg.Providers.Anthropic.AuthMethod = ""
|
||||
case "google-antigravity", "antigravity":
|
||||
cfg.Providers.Antigravity.AuthMethod = ""
|
||||
}
|
||||
|
||||
config.SaveConfig(configPath, cfg)
|
||||
}
|
||||
|
||||
// clearAllAuthMethodsInConfig clears auth_method for all providers in config.json.
|
||||
func clearAllAuthMethodsInConfig(configPath string) {
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for i := range cfg.ModelList {
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
cfg.Providers.OpenAI.AuthMethod = ""
|
||||
cfg.Providers.Anthropic.AuthMethod = ""
|
||||
cfg.Providers.Antigravity.AuthMethod = ""
|
||||
config.SaveConfig(configPath, cfg)
|
||||
}
|
||||
|
||||
// ── Model identification helpers ─────────────────────────────────
|
||||
|
||||
func isOpenAIModel(model string) bool {
|
||||
return model == "openai" || strings.HasPrefix(model, "openai/")
|
||||
}
|
||||
|
||||
func isAnthropicModel(model string) bool {
|
||||
return model == "anthropic" || strings.HasPrefix(model, "anthropic/")
|
||||
}
|
||||
|
||||
func isAntigravityModel(model string) bool {
|
||||
return model == "antigravity" || model == "google-antigravity" ||
|
||||
strings.HasPrefix(model, "antigravity/") || strings.HasPrefix(model, "google-antigravity/")
|
||||
}
|
||||
222
cmd/picoclaw-launcher/internal/server/auth_config_test.go
Normal file
222
cmd/picoclaw-launcher/internal/server/auth_config_test.go
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// ── Model identification helpers ─────────────────────────────────
|
||||
|
||||
func TestIsOpenAIModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"openai", true},
|
||||
{"openai/gpt-4o", true},
|
||||
{"openai/gpt-5.2", true},
|
||||
{"anthropic", false},
|
||||
{"anthropic/claude-sonnet-4.6", false},
|
||||
{"openai-compatible", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isOpenAIModel(tt.model); got != tt.want {
|
||||
t.Errorf("isOpenAIModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAnthropicModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"anthropic", true},
|
||||
{"anthropic/claude-sonnet-4.6", true},
|
||||
{"openai", false},
|
||||
{"openai/gpt-4o", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isAnthropicModel(tt.model); got != tt.want {
|
||||
t.Errorf("isAnthropicModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAntigravityModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"antigravity", true},
|
||||
{"google-antigravity", true},
|
||||
{"antigravity/gemini-3-flash", true},
|
||||
{"google-antigravity/gemini-3-flash", true},
|
||||
{"openai", false},
|
||||
{"antigravity-custom", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isAntigravityModel(tt.model); got != tt.want {
|
||||
t.Errorf("isAntigravityModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config update helpers ────────────────────────────────────────
|
||||
|
||||
func writeTempConfigViaSave(t *testing.T, cfg *config.Config) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
if err := config.SaveConfig(path, cfg); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func loadTempConfig(t *testing.T, path string) *config.Config {
|
||||
t.Helper()
|
||||
cfg, err := config.LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestUpdateConfigAfterLogin_OpenAI_ExistingModel(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
|
||||
},
|
||||
}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||
updateConfigAfterLogin(path, "openai", cred)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
// Model-level auth_method persists through serialization
|
||||
if len(result.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model, got %d", len(result.ModelList))
|
||||
}
|
||||
if result.ModelList[0].AuthMethod != "oauth" {
|
||||
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigAfterLogin_OpenAI_NoExistingModel(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6"},
|
||||
},
|
||||
}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||
updateConfigAfterLogin(path, "openai", cred)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
if len(result.ModelList) != 2 {
|
||||
t.Fatalf("expected 2 models (original + added), got %d", len(result.ModelList))
|
||||
}
|
||||
if result.ModelList[1].Model != "openai/gpt-5.2" {
|
||||
t.Errorf("expected added model openai/gpt-5.2, got %q", result.ModelList[1].Model)
|
||||
}
|
||||
if result.Agents.Defaults.ModelName != "gpt-5.2" {
|
||||
t.Errorf("expected default model_name=gpt-5.2, got %q", result.Agents.Defaults.ModelName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigAfterLogin_Anthropic(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
cred := &auth.AuthCredential{AuthMethod: "token"}
|
||||
updateConfigAfterLogin(path, "anthropic", cred)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
// Model should be added with correct auth_method
|
||||
if len(result.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
|
||||
}
|
||||
if result.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", result.ModelList[0].Model)
|
||||
}
|
||||
if result.ModelList[0].AuthMethod != "token" {
|
||||
t.Errorf("expected model auth_method=token, got %q", result.ModelList[0].AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigAfterLogin_GoogleAntigravity(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||
updateConfigAfterLogin(path, "google-antigravity", cred)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
// Model should be added with correct auth_method
|
||||
if len(result.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
|
||||
}
|
||||
if result.ModelList[0].Model != "antigravity/gemini-3-flash" {
|
||||
t.Errorf("expected model antigravity/gemini-3-flash, got %q", result.ModelList[0].Model)
|
||||
}
|
||||
if result.ModelList[0].AuthMethod != "oauth" {
|
||||
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAuthMethodInConfig(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
|
||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||
},
|
||||
}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
clearAuthMethodInConfig(path, "openai")
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
// Openai model auth_method should be cleared
|
||||
if result.ModelList[0].AuthMethod != "" {
|
||||
t.Errorf("expected openai model auth_method cleared, got %q", result.ModelList[0].AuthMethod)
|
||||
}
|
||||
// Anthropic model should be unchanged
|
||||
if result.ModelList[1].AuthMethod != "token" {
|
||||
t.Errorf("expected anthropic model auth_method unchanged, got %q", result.ModelList[1].AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAllAuthMethodsInConfig(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
|
||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||
{ModelName: "gemini", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth"},
|
||||
},
|
||||
}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
clearAllAuthMethodsInConfig(path)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
for i, m := range result.ModelList {
|
||||
if m.AuthMethod != "" {
|
||||
t.Errorf("model[%d] auth_method not cleared, got %q", i, m.AuthMethod)
|
||||
}
|
||||
}
|
||||
}
|
||||
312
cmd/picoclaw-launcher/internal/server/auth_handlers.go
Normal file
312
cmd/picoclaw-launcher/internal/server/auth_handlers.go
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// oauthSession stores in-flight OAuth state for browser-based flows.
|
||||
type oauthSession struct {
|
||||
Provider string
|
||||
PKCE auth.PKCECodes
|
||||
State string
|
||||
RedirectURI string
|
||||
OAuthCfg auth.OAuthProviderConfig
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
// deviceCodeSession stores in-flight device code flow state.
|
||||
type deviceCodeSession struct {
|
||||
mu sync.Mutex
|
||||
Provider string
|
||||
Info *auth.DeviceCodeInfo
|
||||
OAuthCfg auth.OAuthProviderConfig
|
||||
ConfigPath string
|
||||
Status string // "pending", "success", "error"
|
||||
Error string
|
||||
Done bool
|
||||
}
|
||||
|
||||
var (
|
||||
oauthSessions = map[string]*oauthSession{} // keyed by state
|
||||
oauthSessionsMu sync.Mutex
|
||||
|
||||
activeDeviceSession *deviceCodeSession
|
||||
activeDeviceSessionMu sync.Mutex
|
||||
)
|
||||
|
||||
// handleOpenAILogin starts the OpenAI device code flow and returns device code info to the frontend.
|
||||
func handleOpenAILogin(w http.ResponseWriter, configPath string) {
|
||||
// Check if there's already a pending device code session
|
||||
activeDeviceSessionMu.Lock()
|
||||
if activeDeviceSession != nil {
|
||||
activeDeviceSession.mu.Lock()
|
||||
if !activeDeviceSession.Done {
|
||||
resp := map[string]any{
|
||||
"status": "pending",
|
||||
"device_url": activeDeviceSession.Info.VerifyURL,
|
||||
"user_code": activeDeviceSession.Info.UserCode,
|
||||
"message": "Device code flow already in progress. Enter the code in your browser.",
|
||||
}
|
||||
activeDeviceSession.mu.Unlock()
|
||||
activeDeviceSessionMu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
}
|
||||
activeDeviceSession.mu.Unlock()
|
||||
}
|
||||
activeDeviceSessionMu.Unlock()
|
||||
|
||||
// Request a device code
|
||||
oauthCfg := auth.OpenAIOAuthConfig()
|
||||
info, err := auth.RequestDeviceCode(oauthCfg)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to request device code: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
session := &deviceCodeSession{
|
||||
Provider: "openai",
|
||||
Info: info,
|
||||
OAuthCfg: oauthCfg,
|
||||
ConfigPath: configPath,
|
||||
Status: "pending",
|
||||
}
|
||||
|
||||
activeDeviceSessionMu.Lock()
|
||||
activeDeviceSession = session
|
||||
activeDeviceSessionMu.Unlock()
|
||||
|
||||
// Start background polling
|
||||
go func() {
|
||||
deadline := time.After(15 * time.Minute)
|
||||
ticker := time.NewTicker(time.Duration(info.Interval) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
session.mu.Lock()
|
||||
session.Status = "error"
|
||||
session.Error = "Authentication timed out after 15 minutes"
|
||||
session.Done = true
|
||||
session.mu.Unlock()
|
||||
return
|
||||
case <-ticker.C:
|
||||
cred, err := auth.PollDeviceCodeOnce(oauthCfg, info.DeviceAuthID, info.UserCode)
|
||||
if err != nil {
|
||||
continue // Still pending
|
||||
}
|
||||
if cred != nil {
|
||||
if saveErr := auth.SetCredential("openai", cred); saveErr != nil {
|
||||
session.mu.Lock()
|
||||
session.Status = "error"
|
||||
session.Error = saveErr.Error()
|
||||
session.Done = true
|
||||
session.mu.Unlock()
|
||||
return
|
||||
}
|
||||
updateConfigAfterLogin(configPath, "openai", cred)
|
||||
session.mu.Lock()
|
||||
session.Status = "success"
|
||||
session.Done = true
|
||||
session.mu.Unlock()
|
||||
log.Printf("OpenAI device code login successful (account: %s)", cred.AccountID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Return device code info to frontend
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "pending",
|
||||
"device_url": info.VerifyURL,
|
||||
"user_code": info.UserCode,
|
||||
"message": "Open the URL and enter the code to authenticate.",
|
||||
})
|
||||
}
|
||||
|
||||
// handleAnthropicLogin saves a pasted API token for Anthropic.
|
||||
func handleAnthropicLogin(w http.ResponseWriter, token, configPath string) {
|
||||
if token == "" {
|
||||
http.Error(w, "Token is required for Anthropic login", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cred := &auth.AuthCredential{
|
||||
AccessToken: token,
|
||||
Provider: "anthropic",
|
||||
AuthMethod: "token",
|
||||
}
|
||||
|
||||
if err := auth.SetCredential("anthropic", cred); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save credentials: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
updateConfigAfterLogin(configPath, "anthropic", cred)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "success",
|
||||
"message": "Anthropic token saved",
|
||||
})
|
||||
}
|
||||
|
||||
// handleGoogleAntigravityLogin generates a PKCE + auth URL and returns it to the frontend.
|
||||
func handleGoogleAntigravityLogin(w http.ResponseWriter, r *http.Request, configPath string) {
|
||||
oauthCfg := auth.GoogleAntigravityOAuthConfig()
|
||||
|
||||
pkce, err := auth.GeneratePKCE()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to generate PKCE: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := auth.GenerateState()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to generate state: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build redirect URI pointing to picoclaw-launcher's own callback
|
||||
scheme := "http"
|
||||
redirectURI := fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host)
|
||||
|
||||
authURL := auth.BuildAuthorizeURL(oauthCfg, pkce, state, redirectURI)
|
||||
|
||||
// Store session for callback
|
||||
oauthSessionsMu.Lock()
|
||||
oauthSessions[state] = &oauthSession{
|
||||
Provider: "google-antigravity",
|
||||
PKCE: pkce,
|
||||
State: state,
|
||||
RedirectURI: redirectURI,
|
||||
OAuthCfg: oauthCfg,
|
||||
ConfigPath: configPath,
|
||||
}
|
||||
oauthSessionsMu.Unlock()
|
||||
|
||||
// Clean up stale sessions after 10 minutes
|
||||
go func() {
|
||||
time.Sleep(10 * time.Minute)
|
||||
oauthSessionsMu.Lock()
|
||||
delete(oauthSessions, state)
|
||||
oauthSessionsMu.Unlock()
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "redirect",
|
||||
"auth_url": authURL,
|
||||
"message": "Open the URL to authenticate with Google.",
|
||||
})
|
||||
}
|
||||
|
||||
// handleOAuthCallback processes the OAuth callback from Google Antigravity.
|
||||
func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state := r.URL.Query().Get("state")
|
||||
code := r.URL.Query().Get("code")
|
||||
|
||||
oauthSessionsMu.Lock()
|
||||
session, ok := oauthSessions[state]
|
||||
if ok {
|
||||
delete(oauthSessions, state)
|
||||
}
|
||||
oauthSessionsMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
http.Error(w, "Invalid or expired OAuth state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
errMsg := r.URL.Query().Get("error")
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
|
||||
errMsg,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
cred, err := auth.ExchangeCodeForTokens(session.OAuthCfg, code, session.PKCE.CodeVerifier, session.RedirectURI)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
|
||||
err.Error(),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
cred.Provider = session.Provider
|
||||
|
||||
// Fetch user info for Google Antigravity
|
||||
if session.Provider == "google-antigravity" {
|
||||
if email, err := fetchGoogleUserEmail(cred.AccessToken); err == nil {
|
||||
cred.Email = email
|
||||
}
|
||||
if projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken); err == nil {
|
||||
cred.ProjectID = projectID
|
||||
}
|
||||
}
|
||||
|
||||
if err := auth.SetCredential(session.Provider, cred); err != nil {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<html><body><h2>Failed to save credentials</h2><p>%s</p></body></html>`, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updateConfigAfterLogin(session.ConfigPath, session.Provider, cred)
|
||||
|
||||
// Redirect back to picoclaw-launcher UI
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<html><body>
|
||||
<h2>Authentication successful!</h2>
|
||||
<p>Redirecting back to Config Editor...</p>
|
||||
<script>setTimeout(function(){ window.location.href = '/#auth'; }, 1000);</script>
|
||||
</body></html>`)
|
||||
}
|
||||
|
||||
// fetchGoogleUserEmail retrieves the user's email from Google's userinfo endpoint.
|
||||
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||
}
|
||||
|
||||
var userInfo struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userInfo.Email, nil
|
||||
}
|
||||
99
cmd/picoclaw-launcher/internal/server/logbuffer.go
Normal file
99
cmd/picoclaw-launcher/internal/server/logbuffer.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package server
|
||||
|
||||
import "sync"
|
||||
|
||||
// LogBuffer is a thread-safe ring buffer that stores the most recent N log lines.
|
||||
// It supports incremental reads via LinesSince and tracks a runID that increments
|
||||
// on each Reset (used to detect gateway restarts).
|
||||
type LogBuffer struct {
|
||||
mu sync.RWMutex
|
||||
lines []string
|
||||
cap int
|
||||
total int // total lines ever appended in current run
|
||||
runID int
|
||||
}
|
||||
|
||||
// NewLogBuffer creates a LogBuffer with the given capacity.
|
||||
func NewLogBuffer(capacity int) *LogBuffer {
|
||||
return &LogBuffer{
|
||||
lines: make([]string, 0, capacity),
|
||||
cap: capacity,
|
||||
}
|
||||
}
|
||||
|
||||
// Append adds a line to the buffer. If the buffer is full, the oldest line is evicted.
|
||||
func (b *LogBuffer) Append(line string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if len(b.lines) < b.cap {
|
||||
b.lines = append(b.lines, line)
|
||||
} else {
|
||||
b.lines[b.total%b.cap] = line
|
||||
}
|
||||
|
||||
b.total++
|
||||
}
|
||||
|
||||
// Reset clears the buffer and increments the runID. Call this when starting a new gateway process.
|
||||
func (b *LogBuffer) Reset() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
b.lines = b.lines[:0]
|
||||
b.total = 0
|
||||
b.runID++
|
||||
}
|
||||
|
||||
// LinesSince returns lines appended after the given offset, the current total count, and the runID.
|
||||
// If offset >= total, no lines are returned. If offset is too old (evicted), all buffered lines are returned.
|
||||
func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
total = b.total
|
||||
runID = b.runID
|
||||
|
||||
if offset >= b.total {
|
||||
return nil, total, runID
|
||||
}
|
||||
|
||||
buffered := len(b.lines)
|
||||
|
||||
// How many new lines since offset
|
||||
newCount := b.total - offset
|
||||
if newCount > buffered {
|
||||
newCount = buffered
|
||||
}
|
||||
|
||||
result := make([]string, newCount)
|
||||
|
||||
if b.total <= b.cap {
|
||||
// Buffer hasn't wrapped yet — simple slice
|
||||
copy(result, b.lines[buffered-newCount:])
|
||||
} else {
|
||||
// Buffer has wrapped — read from ring
|
||||
start := (b.total - newCount) % b.cap
|
||||
for i := range newCount {
|
||||
result[i] = b.lines[(start+i)%b.cap]
|
||||
}
|
||||
}
|
||||
|
||||
return result, total, runID
|
||||
}
|
||||
|
||||
// RunID returns the current run identifier.
|
||||
func (b *LogBuffer) RunID() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
return b.runID
|
||||
}
|
||||
|
||||
// Total returns the total number of lines appended in the current run.
|
||||
func (b *LogBuffer) Total() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
return b.total
|
||||
}
|
||||
116
cmd/picoclaw-launcher/internal/server/logbuffer_test.go
Normal file
116
cmd/picoclaw-launcher/internal/server/logbuffer_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLogBuffer_Basic(t *testing.T) {
|
||||
buf := NewLogBuffer(5)
|
||||
|
||||
// Empty buffer
|
||||
lines, total, runID := buf.LinesSince(0)
|
||||
assert.Nil(t, lines)
|
||||
assert.Equal(t, 0, total)
|
||||
assert.Equal(t, 0, runID)
|
||||
|
||||
// Append some lines
|
||||
buf.Append("line1")
|
||||
buf.Append("line2")
|
||||
buf.Append("line3")
|
||||
|
||||
lines, total, runID = buf.LinesSince(0)
|
||||
assert.Equal(t, []string{"line1", "line2", "line3"}, lines)
|
||||
assert.Equal(t, 3, total)
|
||||
assert.Equal(t, 0, runID)
|
||||
|
||||
// Incremental read
|
||||
lines, total, _ = buf.LinesSince(2)
|
||||
assert.Equal(t, []string{"line3"}, lines)
|
||||
assert.Equal(t, 3, total)
|
||||
|
||||
// No new lines
|
||||
lines, total, _ = buf.LinesSince(3)
|
||||
assert.Nil(t, lines)
|
||||
assert.Equal(t, 3, total)
|
||||
}
|
||||
|
||||
func TestLogBuffer_Wrap(t *testing.T) {
|
||||
buf := NewLogBuffer(3)
|
||||
|
||||
buf.Append("a")
|
||||
buf.Append("b")
|
||||
buf.Append("c")
|
||||
buf.Append("d") // evicts "a"
|
||||
buf.Append("e") // evicts "b"
|
||||
|
||||
lines, total, _ := buf.LinesSince(0)
|
||||
assert.Equal(t, []string{"c", "d", "e"}, lines)
|
||||
assert.Equal(t, 5, total)
|
||||
|
||||
// Incremental after wrap
|
||||
lines, total, _ = buf.LinesSince(3)
|
||||
assert.Equal(t, []string{"d", "e"}, lines)
|
||||
assert.Equal(t, 5, total)
|
||||
|
||||
// Offset too old (before buffer start), get all buffered
|
||||
lines, total, _ = buf.LinesSince(1)
|
||||
assert.Equal(t, []string{"c", "d", "e"}, lines)
|
||||
assert.Equal(t, 5, total)
|
||||
}
|
||||
|
||||
func TestLogBuffer_Reset(t *testing.T) {
|
||||
buf := NewLogBuffer(5)
|
||||
|
||||
buf.Append("before")
|
||||
assert.Equal(t, 0, buf.RunID())
|
||||
|
||||
buf.Reset()
|
||||
assert.Equal(t, 1, buf.RunID())
|
||||
assert.Equal(t, 0, buf.Total())
|
||||
|
||||
lines, total, runID := buf.LinesSince(0)
|
||||
assert.Nil(t, lines)
|
||||
assert.Equal(t, 0, total)
|
||||
assert.Equal(t, 1, runID)
|
||||
|
||||
buf.Append("after")
|
||||
lines, total, runID = buf.LinesSince(0)
|
||||
assert.Equal(t, []string{"after"}, lines)
|
||||
assert.Equal(t, 1, total)
|
||||
assert.Equal(t, 1, runID)
|
||||
}
|
||||
|
||||
func TestLogBuffer_Concurrent(t *testing.T) {
|
||||
buf := NewLogBuffer(100)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 10 writers
|
||||
for i := range 10 {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := range 50 {
|
||||
buf.Append(fmt.Sprintf("writer-%d-line-%d", id, j))
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 5 readers
|
||||
for range 5 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range 100 {
|
||||
buf.LinesSince(0)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, 500, buf.Total())
|
||||
}
|
||||
232
cmd/picoclaw-launcher/internal/server/process.go
Normal file
232
cmd/picoclaw-launcher/internal/server/process.go
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// gatewayLogs stores captured stdout/stderr from the gateway process launched by the launcher.
|
||||
var gatewayLogs = NewLogBuffer(200)
|
||||
|
||||
// RegisterProcessAPI registers endpoints to start, stop and check status of the picoclaw gateway.
|
||||
func RegisterProcessAPI(mux *http.ServeMux, absPath string) {
|
||||
mux.HandleFunc("GET /api/process/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleStatusGateway(w, r, absPath)
|
||||
})
|
||||
mux.HandleFunc("POST /api/process/start", handleStartGateway)
|
||||
mux.HandleFunc("POST /api/process/stop", handleStopGateway)
|
||||
}
|
||||
|
||||
func handleStartGateway(w http.ResponseWriter, r *http.Request) {
|
||||
// Locate picoclaw executable:
|
||||
// 1. Try same directory as current executable
|
||||
// 2. Fallback to just "picoclaw" (relies on $PATH)
|
||||
execPath := "picoclaw"
|
||||
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
candidate := filepath.Join(dir, "picoclaw")
|
||||
if runtime.GOOS == "windows" {
|
||||
candidate += ".exe"
|
||||
}
|
||||
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
execPath = candidate
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(execPath, "gateway")
|
||||
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
log.Printf("Failed to create stdout pipe: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
log.Printf("Failed to create stderr pipe: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear old logs and increment runID before starting
|
||||
gatewayLogs.Reset()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("Failed to start picoclaw gateway: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Read stdout and stderr into the log buffer
|
||||
go scanPipe(stdoutPipe, gatewayLogs)
|
||||
go scanPipe(stderrPipe, gatewayLogs)
|
||||
|
||||
// Wait for the process to exit in the background to avoid zombies
|
||||
go func() {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
log.Printf("Gateway process exited: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("Started picoclaw gateway (PID: %d) from %s\n", cmd.Process.Pid, execPath)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"pid": cmd.Process.Pid,
|
||||
})
|
||||
}
|
||||
|
||||
// scanPipe reads lines from r and appends them to buf. It returns when r reaches EOF.
|
||||
func scanPipe(r io.Reader, buf *LogBuffer) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // up to 1MB per line
|
||||
|
||||
for scanner.Scan() {
|
||||
buf.Append(scanner.Text())
|
||||
}
|
||||
}
|
||||
|
||||
func handleStopGateway(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
if runtime.GOOS == "windows" {
|
||||
// Kill via taskkill finding picoclaw.exe (though it might kill this config tool if it's named picoclaw-launcher.exe...? No, /IM does exact match usually, but just to be safe let's stop exactly picoclaw.exe)
|
||||
// Alternatively, we use powershell to kill processes with commandline containing 'gateway'
|
||||
psCmd := `Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -match 'picoclaw.*gateway' } | ForEach-Object { Stop-Process $_.ProcessId -Force }`
|
||||
err = exec.Command("powershell", "-Command", psCmd).Run()
|
||||
} else {
|
||||
// Linux/macOS
|
||||
err = exec.Command("pkill", "-f", "picoclaw gateway").Run()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to stop gateway (perhaps not running?): %v\n", err)
|
||||
// We still return 200 OK because pkill returns an error if no process was found
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok", // or "not_found"
|
||||
"msg": "Stop command executed, but returned error (process might not be running).",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Stopped picoclaw gateway processes.\n")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
func handleStatusGateway(w http.ResponseWriter, r *http.Request, absPath string) {
|
||||
cfg, cfgErr := config.LoadConfig(absPath)
|
||||
host := "127.0.0.1"
|
||||
port := 18790
|
||||
if cfgErr == nil && cfg != nil {
|
||||
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
|
||||
host = cfg.Gateway.Host
|
||||
}
|
||||
if cfg.Gateway.Port != 0 {
|
||||
port = cfg.Gateway.Port
|
||||
}
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
client := http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
|
||||
// Build the response data map
|
||||
data := map[string]any{}
|
||||
|
||||
if err != nil {
|
||||
data["process_status"] = "stopped"
|
||||
data["error"] = err.Error()
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
data["process_status"] = "error"
|
||||
data["status_code"] = resp.StatusCode
|
||||
} else {
|
||||
var healthData map[string]any
|
||||
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
|
||||
data["process_status"] = "error"
|
||||
data["error"] = "invalid response from gateway"
|
||||
} else {
|
||||
// Gateway is running and responded properly — merge health data
|
||||
for k, v := range healthData {
|
||||
data[k] = v
|
||||
}
|
||||
data["process_status"] = "running"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append log data from the buffer
|
||||
appendLogData(r, data)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// appendLogData reads log_offset and log_run_id query params from the request and
|
||||
// populates the response data map with incremental log lines.
|
||||
func appendLogData(r *http.Request, data map[string]any) {
|
||||
clientOffset := 0
|
||||
clientRunID := -1
|
||||
|
||||
if v := r.URL.Query().Get("log_offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
clientOffset = n
|
||||
}
|
||||
}
|
||||
|
||||
if v := r.URL.Query().Get("log_run_id"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
clientRunID = n
|
||||
}
|
||||
}
|
||||
|
||||
runID := gatewayLogs.RunID()
|
||||
|
||||
// If runID is 0 (never reset = never launched from this launcher), report no source
|
||||
if runID == 0 {
|
||||
data["logs"] = []string{}
|
||||
data["log_total"] = 0
|
||||
data["log_run_id"] = 0
|
||||
data["log_source"] = "none"
|
||||
return
|
||||
}
|
||||
|
||||
// If the client's runID doesn't match, send all buffered lines (gateway restarted)
|
||||
offset := clientOffset
|
||||
if clientRunID != runID {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
lines, total, runID := gatewayLogs.LinesSince(offset)
|
||||
if lines == nil {
|
||||
lines = []string{}
|
||||
}
|
||||
|
||||
data["logs"] = lines
|
||||
data["log_total"] = total
|
||||
data["log_run_id"] = runID
|
||||
data["log_source"] = "launcher"
|
||||
}
|
||||
196
cmd/picoclaw-launcher/internal/server/server.go
Normal file
196
cmd/picoclaw-launcher/internal/server/server.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
const DefaultPort = "18800"
|
||||
|
||||
// providerStatus represents the auth status of a single provider in API responses.
|
||||
type providerStatus struct {
|
||||
Provider string `json:"provider"`
|
||||
AuthMethod string `json:"auth_method"`
|
||||
Status string `json:"status"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// ── Route registration ───────────────────────────────────────────
|
||||
|
||||
func RegisterConfigAPI(mux *http.ServeMux, absPath string) {
|
||||
// GET /api/config — read config
|
||||
mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := config.LoadConfig(absPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp := map[string]any{
|
||||
"config": cfg,
|
||||
"path": absPath,
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(resp); err != nil {
|
||||
log.Printf("Failed to encode response: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/config — save config
|
||||
mux.HandleFunc("PUT /api/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var cfg config.Config
|
||||
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(absPath, &cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
}
|
||||
|
||||
func RegisterAuthAPI(mux *http.ServeMux, absPath string) {
|
||||
// GET /api/auth/status — all authenticated providers + pending login state
|
||||
mux.HandleFunc("GET /api/auth/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
store, err := auth.LoadStore()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load auth store: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result := []providerStatus{}
|
||||
for name, cred := range store.Credentials {
|
||||
status := "active"
|
||||
if cred.IsExpired() {
|
||||
status = "expired"
|
||||
} else if cred.NeedsRefresh() {
|
||||
status = "needs_refresh"
|
||||
}
|
||||
ps := providerStatus{
|
||||
Provider: name,
|
||||
AuthMethod: cred.AuthMethod,
|
||||
Status: status,
|
||||
AccountID: cred.AccountID,
|
||||
Email: cred.Email,
|
||||
ProjectID: cred.ProjectID,
|
||||
}
|
||||
if !cred.ExpiresAt.IsZero() {
|
||||
ps.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
result = append(result, ps)
|
||||
}
|
||||
|
||||
// Include pending device code state
|
||||
var pendingDevice map[string]any
|
||||
activeDeviceSessionMu.Lock()
|
||||
if activeDeviceSession != nil {
|
||||
activeDeviceSession.mu.Lock()
|
||||
pendingDevice = map[string]any{
|
||||
"provider": activeDeviceSession.Provider,
|
||||
"status": activeDeviceSession.Status,
|
||||
"device_url": activeDeviceSession.Info.VerifyURL,
|
||||
"user_code": activeDeviceSession.Info.UserCode,
|
||||
}
|
||||
if activeDeviceSession.Error != "" {
|
||||
pendingDevice["error"] = activeDeviceSession.Error
|
||||
}
|
||||
if activeDeviceSession.Done {
|
||||
activeDeviceSession.mu.Unlock()
|
||||
activeDeviceSession = nil
|
||||
} else {
|
||||
activeDeviceSession.mu.Unlock()
|
||||
}
|
||||
}
|
||||
activeDeviceSessionMu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"providers": result,
|
||||
"pending_device": pendingDevice,
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/auth/login — initiate provider login
|
||||
mux.HandleFunc("POST /api/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Provider {
|
||||
case "openai":
|
||||
handleOpenAILogin(w, absPath)
|
||||
case "anthropic":
|
||||
handleAnthropicLogin(w, req.Token, absPath)
|
||||
case "google-antigravity", "antigravity":
|
||||
handleGoogleAntigravityLogin(w, r, absPath)
|
||||
default:
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf(
|
||||
"Unsupported provider: %s (supported: openai, anthropic, google-antigravity)",
|
||||
req.Provider,
|
||||
),
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/auth/logout — logout a provider
|
||||
mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Provider == "" {
|
||||
if err := auth.DeleteAllCredentials(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
clearAllAuthMethodsInConfig(absPath)
|
||||
} else {
|
||||
if err := auth.DeleteCredential(req.Provider); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
clearAuthMethodInConfig(absPath, req.Provider)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// GET /auth/callback — OAuth browser callback for Google Antigravity
|
||||
mux.HandleFunc("GET /auth/callback", handleOAuthCallback)
|
||||
}
|
||||
247
cmd/picoclaw-launcher/internal/server/server_test.go
Normal file
247
cmd/picoclaw-launcher/internal/server/server_test.go
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// ── Config API tests ─────────────────────────────────────────────
|
||||
|
||||
func setupConfigMux(t *testing.T, cfg *config.Config) (*http.ServeMux, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
RegisterConfigAPI(mux, path)
|
||||
RegisterAuthAPI(mux, path)
|
||||
return mux, path
|
||||
}
|
||||
|
||||
func TestGetConfig(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
|
||||
},
|
||||
}
|
||||
mux, path := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Config config.Config `json:"config"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Path != path {
|
||||
t.Errorf("expected path %q, got %q", path, resp.Path)
|
||||
}
|
||||
if len(resp.Config.ModelList) != 1 {
|
||||
t.Errorf("expected 1 model, got %d", len(resp.Config.ModelList))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfig_MissingFile_ReturnsDefault(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
RegisterConfigAPI(mux, "/tmp/nonexistent-picoclaw-launcher-test/config.json")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
// LoadConfig returns a default empty config when file is missing
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for missing file (default config), got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutConfig(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, path := setupConfigMux(t, cfg)
|
||||
|
||||
newCfg := config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(newCfg)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("PUT /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
saved, err := config.LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load saved config: %v", err)
|
||||
}
|
||||
if len(saved.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model saved, got %d", len(saved.ModelList))
|
||||
}
|
||||
if saved.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", saved.ModelList[0].Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutConfig_InvalidJSON(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader("{invalid"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid JSON, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth API tests ───────────────────────────────────────────────
|
||||
|
||||
func TestAuthStatus(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/auth/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/auth/status: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Providers []providerStatus `json:"providers"`
|
||||
PendingDevice map[string]any `json:"pending_device"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
// providers should be a non-nil list (could be empty)
|
||||
if resp.Providers == nil {
|
||||
t.Error("providers should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_UnsupportedProvider(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
body := `{"provider": "unsupported"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for unsupported provider, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_AnthropicNoToken(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
body := `{"provider": "anthropic"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for anthropic without token, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_InvalidBody(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader("{bad"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid JSON body, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogout_InvalidBody(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/auth/logout", strings.NewReader("{bad"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid body, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthCallback_InvalidState(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=invalid&code=test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid state, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utility tests ────────────────────────────────────────────────
|
||||
|
||||
func TestDefaultConfigPath(t *testing.T) {
|
||||
path := DefaultConfigPath()
|
||||
if path == "" {
|
||||
t.Error("defaultConfigPath should not return empty")
|
||||
}
|
||||
if !strings.HasSuffix(path, filepath.Join(".picoclaw", "config.json")) {
|
||||
t.Errorf("expected path ending with .picoclaw/config.json, got %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLocalIP(t *testing.T) {
|
||||
// Just ensure it doesn't panic; IP may or may not be available
|
||||
ip := GetLocalIP()
|
||||
if ip != "" {
|
||||
// If returned, should look like an IP
|
||||
if !strings.Contains(ip, ".") {
|
||||
t.Errorf("getLocalIP returned non-IPv4 looking string: %q", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
28
cmd/picoclaw-launcher/internal/server/utils.go
Normal file
28
cmd/picoclaw-launcher/internal/server/utils.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func DefaultConfigPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "config.json"
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw", "config.json")
|
||||
}
|
||||
|
||||
func GetLocalIP() string {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
|
||||
return ipnet.IP.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
1999
cmd/picoclaw-launcher/internal/ui/index.html
Normal file
1999
cmd/picoclaw-launcher/internal/ui/index.html
Normal file
File diff suppressed because it is too large
Load diff
127
cmd/picoclaw-launcher/main.go
Normal file
127
cmd/picoclaw-launcher/main.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// PicoClaw Launcher - Standalone HTTP service
|
||||
//
|
||||
// Provides a web-based JSON editor for picoclaw config files,
|
||||
// with OAuth provider authentication support.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||
// ./picoclaw-launcher [config.json]
|
||||
// ./picoclaw-launcher -public config.json
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher/internal/server"
|
||||
)
|
||||
|
||||
//go:embed internal/ui/index.html
|
||||
var staticFiles embed.FS
|
||||
|
||||
func main() {
|
||||
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, "Arguments:\n")
|
||||
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Options:\n")
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
||||
fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
|
||||
fmt.Fprintf(
|
||||
os.Stderr,
|
||||
" %s -public ./config.json Allow access from other devices on the network\n",
|
||||
os.Args[0],
|
||||
)
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
configPath := server.DefaultConfigPath()
|
||||
if flag.NArg() > 0 {
|
||||
configPath = flag.Arg(0)
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to resolve config path: %v", err)
|
||||
}
|
||||
|
||||
var addr string
|
||||
if *public {
|
||||
addr = "0.0.0.0:" + server.DefaultPort
|
||||
} else {
|
||||
addr = "127.0.0.1:" + server.DefaultPort
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
server.RegisterConfigAPI(mux, absPath)
|
||||
server.RegisterAuthAPI(mux, absPath)
|
||||
server.RegisterProcessAPI(mux, absPath)
|
||||
|
||||
staticFS, err := fs.Sub(staticFiles, "internal/ui")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create sub filesystem: %v", err)
|
||||
}
|
||||
mux.Handle("/", http.FileServer(http.FS(staticFS)))
|
||||
|
||||
// Print startup banner
|
||||
fmt.Println("=============================================")
|
||||
fmt.Println(" PicoClaw Launcher")
|
||||
fmt.Println("=============================================")
|
||||
fmt.Printf(" Config file : %s\n", absPath)
|
||||
fmt.Printf(" Listen addr : %s\n\n", addr)
|
||||
fmt.Println(" Open the following URL in your browser")
|
||||
fmt.Println(" to view and edit the configuration:")
|
||||
fmt.Println()
|
||||
fmt.Printf(" >> http://localhost:%s <<\n", server.DefaultPort)
|
||||
if *public {
|
||||
if ip := server.GetLocalIP(); ip != "" {
|
||||
fmt.Printf(" >> http://%s:%s <<\n", ip, server.DefaultPort)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
// fmt.Println("=============================================")
|
||||
|
||||
go func() {
|
||||
// Wait briefly to ensure the server is ready before opening the browser
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
url := "http://localhost:" + server.DefaultPort
|
||||
if err := openBrowser(url); err != nil {
|
||||
log.Printf("Warning: Failed to auto-open browser: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// openBrowser automatically opens the given URL in the default browser.
|
||||
func openBrowser(url string) error {
|
||||
var err error
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
err = exec.Command("xdg-open", url).Start()
|
||||
case "windows":
|
||||
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
case "darwin":
|
||||
err = exec.Command("open", url).Start()
|
||||
default:
|
||||
err = fmt.Errorf("unsupported platform")
|
||||
}
|
||||
return err
|
||||
}
|
||||
22
cmd/picoclaw-launcher/winres/winres.json
Normal file
22
cmd/picoclaw-launcher/winres/winres.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"RT_GROUP_ICON": {
|
||||
"APP": {
|
||||
"0000": "../icon.ico"
|
||||
}
|
||||
},
|
||||
"RT_MANIFEST": {
|
||||
"#1": {
|
||||
"0409": {
|
||||
"identity": {
|
||||
"name": "PicoClaw Launcher",
|
||||
"version": "0.0.0.0"
|
||||
},
|
||||
"description": "PicoClaw Launcher - Web-based configuration editor",
|
||||
"minimum-os": "win7",
|
||||
"execution-level": "asInvoker",
|
||||
"dpi-awareness": "system",
|
||||
"use-common-controls-v6": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -370,7 +370,7 @@ func (p *agentLoopDataProvider) ListSkills() []skills.SkillInfo {
|
|||
func (p *agentLoopDataProvider) GetPlanInfo() miniapp.PlanInfo {
|
||||
hasPlan, status, currentPhase, totalPhases, display, memory := p.loop.GetPlanInfo()
|
||||
|
||||
// Convert agent.PlanPhase → miniapp.PlanPhase
|
||||
// Convert agent.PlanPhase -> miniapp.PlanPhase
|
||||
agentPhases := p.loop.GetPlanPhases()
|
||||
phases := make([]miniapp.PlanPhase, 0, len(agentPhases))
|
||||
for _, ap := range agentPhases {
|
||||
|
|
|
|||
|
|
@ -11,19 +11,21 @@ func NewMigrateCommand() *cobra.Command {
|
|||
|
||||
cmd := &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Migrate from OpenClaw to PicoClaw",
|
||||
Short: "Migrate from xxxclaw(openclaw, etc.) to picoclaw",
|
||||
Args: cobra.NoArgs,
|
||||
Example: ` picoclaw migrate
|
||||
picoclaw migrate --from openclaw
|
||||
picoclaw migrate --dry-run
|
||||
picoclaw migrate --refresh
|
||||
picoclaw migrate --force`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
result, err := migrate.Run(opts)
|
||||
m := migrate.NewMigrateInstance(opts)
|
||||
result, err := m.Run(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !opts.DryRun {
|
||||
migrate.PrintSummary(result)
|
||||
m.PrintSummary(result)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
|
@ -31,6 +33,8 @@ func NewMigrateCommand() *cobra.Command {
|
|||
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
||||
"Show what would be migrated without making changes")
|
||||
cmd.Flags().StringVar(&opts.Source, "from", "openclaw",
|
||||
"Source to migrate from (e.g., openclaw)")
|
||||
cmd.Flags().BoolVar(&opts.Refresh, "refresh", false,
|
||||
"Re-sync workspace files from OpenClaw (repeatable)")
|
||||
cmd.Flags().BoolVar(&opts.ConfigOnly, "config-only", false,
|
||||
|
|
@ -39,10 +43,10 @@ func NewMigrateCommand() *cobra.Command {
|
|||
"Only migrate workspace files, skip config")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false,
|
||||
"Skip confirmation prompts")
|
||||
cmd.Flags().StringVar(&opts.OpenClawHome, "openclaw-home", "",
|
||||
"Override OpenClaw home directory (default: ~/.openclaw)")
|
||||
cmd.Flags().StringVar(&opts.PicoClawHome, "picoclaw-home", "",
|
||||
"Override PicoClaw home directory (default: ~/.picoclaw)")
|
||||
cmd.Flags().StringVar(&opts.SourceHome, "source-home", "",
|
||||
"Override source home directory (default: ~/.openclaw)")
|
||||
cmd.Flags().StringVar(&opts.TargetHome, "target-home", "",
|
||||
"Override target home directory (default: ~/.picoclaw)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ func TestNewMigrateCommand(t *testing.T) {
|
|||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "migrate", cmd.Use)
|
||||
assert.Equal(t, "Migrate from OpenClaw to PicoClaw", cmd.Short)
|
||||
assert.Equal(t, "Migrate from xxxclaw(openclaw, etc.) to picoclaw", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
|
||||
|
|
@ -33,6 +33,6 @@ func TestNewMigrateCommand(t *testing.T) {
|
|||
assert.NotNil(t, cmd.Flags().Lookup("config-only"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("workspace-only"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("openclaw-home"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("picoclaw-home"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("source-home"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("target-home"))
|
||||
}
|
||||
|
|
|
|||
25
cmd/picoclaw/internal/onboard/helpers_test.go
Normal file
25
cmd/picoclaw/internal/onboard/helpers_test.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package onboard
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCopyEmbeddedToTargetUsesAgentsMarkdown(t *testing.T) {
|
||||
targetDir := t.TempDir()
|
||||
|
||||
if err := copyEmbeddedToTarget(targetDir); err != nil {
|
||||
t.Fatalf("copyEmbeddedToTarget() error = %v", err)
|
||||
}
|
||||
|
||||
agentsPath := filepath.Join(targetDir, "AGENTS.md")
|
||||
if _, err := os.Stat(agentsPath); err != nil {
|
||||
t.Fatalf("expected %s to exist: %v", agentsPath, err)
|
||||
}
|
||||
|
||||
legacyPath := filepath.Join(targetDir, "AGENT.md")
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -71,7 +71,7 @@ func NewSkillsCommand() *cobra.Command {
|
|||
newInstallBuiltinCommand(workspaceFn),
|
||||
newListBuiltinCommand(),
|
||||
newRemoveCommand(installerFn),
|
||||
newSearchCommand(installerFn),
|
||||
newSearchCommand(),
|
||||
newShowCommand(loaderFn),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
const skillsSearchMaxResults = 20
|
||||
|
||||
func skillsListCmd(loader *skills.SkillsLoader) {
|
||||
allSkills := loader.ListSkills()
|
||||
|
||||
|
|
@ -215,34 +217,43 @@ func skillsListBuiltinCmd() {
|
|||
}
|
||||
}
|
||||
|
||||
func skillsSearchCmd(installer *skills.SkillInstaller) {
|
||||
func skillsSearchCmd(query string) {
|
||||
fmt.Println("Searching for available skills...")
|
||||
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("✗ Failed to load config: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
availableSkills, err := installer.ListAvailableSkills(ctx)
|
||||
results, err := registryMgr.SearchAll(ctx, query, skillsSearchMaxResults)
|
||||
if err != nil {
|
||||
fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(availableSkills) == 0 {
|
||||
if len(results) == 0 {
|
||||
fmt.Println("No skills available.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills))
|
||||
fmt.Printf("\nAvailable Skills (%d):\n", len(results))
|
||||
fmt.Println("--------------------")
|
||||
for _, skill := range availableSkills {
|
||||
fmt.Printf(" 📦 %s\n", skill.Name)
|
||||
fmt.Printf(" %s\n", skill.Description)
|
||||
fmt.Printf(" Repo: %s\n", skill.Repository)
|
||||
if skill.Author != "" {
|
||||
fmt.Printf(" Author: %s\n", skill.Author)
|
||||
}
|
||||
if len(skill.Tags) > 0 {
|
||||
fmt.Printf(" Tags: %v\n", skill.Tags)
|
||||
for _, result := range results {
|
||||
fmt.Printf(" 📦 %s\n", result.DisplayName)
|
||||
fmt.Printf(" %s\n", result.Summary)
|
||||
fmt.Printf(" Slug: %s\n", result.Slug)
|
||||
fmt.Printf(" Registry: %s\n", result.RegistryName)
|
||||
if result.Version != "" {
|
||||
fmt.Printf(" Version: %s\n", result.Version)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,19 @@ package skills
|
|||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
func newSearchCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
||||
func newSearchCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Use: "search [query]",
|
||||
Short: "Search available skills",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
installer, err := installerFn()
|
||||
if err != nil {
|
||||
return err
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
query := ""
|
||||
if len(args) == 1 {
|
||||
query = args[0]
|
||||
}
|
||||
skillsSearchCmd(installer)
|
||||
skillsSearchCmd(query)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ import (
|
|||
)
|
||||
|
||||
func TestNewSearchSubcommand(t *testing.T) {
|
||||
cmd := newSearchCommand(nil)
|
||||
cmd := newSearchCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "search", cmd.Use)
|
||||
assert.Equal(t, "search [query]", cmd.Use)
|
||||
assert.Equal(t, "Search available skills", cmd.Short)
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
|
|
|
|||
|
|
@ -3,15 +3,10 @@
|
|||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true,
|
||||
"provider": "openai",
|
||||
"model_name": "gpt4",
|
||||
"model_fallbacks": [],
|
||||
"plan_model": "",
|
||||
"plan_model_fallbacks": [],
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20,
|
||||
"task_reminder_interval": 5
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
|
|
@ -37,12 +32,6 @@
|
|||
"model": "deepseek/deepseek-chat",
|
||||
"api_key": "sk-your-deepseek-key"
|
||||
},
|
||||
{
|
||||
"model_name": "minimax",
|
||||
"model": "minimax/MiniMax-M1",
|
||||
"api_key": "your-minimax-api-key",
|
||||
"stream": true
|
||||
},
|
||||
{
|
||||
"model_name": "loadbalanced-gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
|
|
@ -61,7 +50,6 @@
|
|||
"enabled": false,
|
||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||
"proxy": "",
|
||||
"web_app_url": "",
|
||||
"allow_from": [
|
||||
"YOUR_USER_ID"
|
||||
],
|
||||
|
|
@ -71,7 +59,9 @@
|
|||
"enabled": false,
|
||||
"token": "YOUR_DISCORD_BOT_TOKEN",
|
||||
"allow_from": [],
|
||||
"mention_only": false,
|
||||
"group_trigger": {
|
||||
"mention_only": false
|
||||
},
|
||||
"reasoning_channel_id": ""
|
||||
},
|
||||
"qq": {
|
||||
|
|
@ -123,8 +113,6 @@
|
|||
"enabled": false,
|
||||
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
||||
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18791,
|
||||
"webhook_path": "/webhook/line",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
|
|
@ -144,8 +132,6 @@
|
|||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18793,
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"allow_from": [],
|
||||
"reply_timeout": 5,
|
||||
|
|
@ -159,8 +145,6 @@
|
|||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18792,
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": [],
|
||||
"reply_timeout": 5,
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ Discord 是一个专为社区设计的免费语音、视频和文本聊天应用
|
|||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"group_trigger": {
|
||||
"mention_only": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -22,7 +24,7 @@ Discord 是一个专为社区设计的免费语音、视频和文本聊天应用
|
|||
| enabled | bool | 是 | 是否启用 Discord 频道 |
|
||||
| token | string | 是 | Discord 机器人 Token |
|
||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||
| mention_only | bool | 否 | 是否仅响应提及机器人的消息 |
|
||||
| group_trigger | object | 否 | 群组触发设置(示例: { "mention_only": false }) |
|
||||
|
||||
## 设置流程
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的
|
|||
"enabled": true,
|
||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18791,
|
||||
"webhook_path": "/webhook/line",
|
||||
"allow_from": []
|
||||
}
|
||||
|
|
@ -25,9 +23,7 @@ PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的
|
|||
| enabled | bool | 是 | 是否启用 LINE Channel |
|
||||
| channel_secret | string | 是 | LINE Messaging API 的 Channel Secret |
|
||||
| channel_access_token | string | 是 | LINE Messaging API 的 Channel Access Token |
|
||||
| webhook_host | string | 是 | Webhook 监听的主机地址 (通常为 0.0.0.0) |
|
||||
| webhook_port | int | 是 | Webhook 监听的端口 (默认为 18791) |
|
||||
| webhook_path | string | 是 | Webhook 的路径 (默认为 /webhook/line) |
|
||||
| webhook_path | string | 否 | Webhook 的路径 (默认为 /webhook/line) |
|
||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||
|
||||
## 设置流程
|
||||
|
|
@ -35,7 +31,8 @@ PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的
|
|||
1. 前往 [LINE Developers Console](https://developers.line.biz/console/) 创建一个服务提供商和一个 Messaging API Channel
|
||||
2. 获取 Channel Secret 和 Channel Access Token
|
||||
3. 配置Webhook:
|
||||
- Line要求Webhook必须使用HTTPS协议,因此需要部署一个支持HTTPS的服务器,或者使用反向代理工具如ngrok将本地服务器暴露到公网
|
||||
- 将 Webhook URL 设置为 `https://your-domain.com/webhook/line`
|
||||
- LINE 要求 Webhook 必须使用 HTTPS 协议,因此需要部署一个支持 HTTPS 的服务器,或者使用反向代理工具如 ngrok 将本地服务器暴露到公网
|
||||
- PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790
|
||||
- 将 Webhook URL 设置为 `https://your-domain.com/webhook/line`,然后将外部域名反向代理到本机的 Gateway(默认端口 18790)
|
||||
- 启用 Webhook 并验证 URL
|
||||
4. 将 Channel Secret 和 Channel Access Token 填入配置文件中
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@
|
|||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18792,
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": [],
|
||||
"reply_timeout": 5
|
||||
|
|
@ -31,8 +29,6 @@
|
|||
| agent_id | int | 是 | 应用程序代理 ID |
|
||||
| token | string | 是 | 回调验证令牌 |
|
||||
| encoding_aes_key | string | 是 | 43 字符 AES 密钥 |
|
||||
| webhook_host | string | 否 | HTTP 服务器绑定地址 |
|
||||
| webhook_port | int | 否 | HTTP 服务器端口(默认:18792) |
|
||||
| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-app) |
|
||||
| allow_from | array | 否 | 用户 ID 白名单 |
|
||||
| reply_timeout | int | 否 | 回复超时时间(秒) |
|
||||
|
|
@ -45,3 +41,5 @@
|
|||
4. 在应用设置中配置“接收消息”,获取 Token 和 EncodingAESKey
|
||||
5. 设置回调 URL 为 `http://<your-server-ip>:<port>/webhook/wecom-app`
|
||||
6. 将 CorpID, Secret, AgentID 等信息填入配置文件
|
||||
|
||||
注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790。如需从公网接收回调,请把外部域名反向代理到 Gateway(默认端口 18790)。
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@
|
|||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18793,
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"allow_from": [],
|
||||
"reply_timeout": 5
|
||||
|
|
@ -27,8 +25,6 @@
|
|||
| token | string | 是 | 签名验证代币 |
|
||||
| encoding_aes_key | string | 是 | 用于解密的 43 字符 AES 密钥 |
|
||||
| webhook_url | string | 是 | 用于发送回复的企业微信群聊机器人 Webhook URL |
|
||||
| webhook_host | string | 否 | HTTP 服务器绑定地址(默认:0.0.0.0) |
|
||||
| webhook_port | int | 否 | HTTP 服务器端口(默认:18793) |
|
||||
| webhook_path | string | 否 | Webhook 端点路径(默认:/webhook/wecom) |
|
||||
| allow_from | array | 否 | 用户 ID 白名单(空值 = 允许所有用户) |
|
||||
| reply_timeout | int | 否 | 回复超时时间(单位:秒,默认值:5) |
|
||||
|
|
@ -39,3 +35,5 @@
|
|||
2. 获取 Webhook URL
|
||||
3. (如需接收消息) 在机器人配置页面设置接收消息的 API 地址(回调地址)以及 Token 和 EncodingAESKey
|
||||
4. 将相关信息填入配置文件
|
||||
|
||||
注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790。如需从公网接收回调,请把外部域名反向代理到 Gateway(默认端口 18790)。
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
1. 在应用详情页,点击"接收消息"的"设置API接收"
|
||||
2. 填写以下信息:
|
||||
- **URL**: `http://your-server:18792/webhook/wecom-app`
|
||||
- **URL**: `http://your-server:18790/webhook/wecom-app`
|
||||
- **Token**: 随机生成或自定义(用于签名验证)
|
||||
- **EncodingAESKey**: 点击"随机生成"生成43字符的密钥
|
||||
3. 点击"保存"时,企业微信会发送验证请求
|
||||
|
|
@ -45,8 +45,6 @@
|
|||
"agent_id": 1000002, // 应用AgentId
|
||||
"token": "your_token", // 接收消息配置的Token
|
||||
"encoding_aes_key": "your_encoding_aes_key", // 接收消息配置的EncodingAESKey
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18792,
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": [],
|
||||
"reply_timeout": 5
|
||||
|
|
@ -62,7 +60,7 @@
|
|||
**症状**: 企业微信保存API接收消息时提示验证失败
|
||||
|
||||
**检查项**:
|
||||
- 确认服务器防火墙已开放 18792 端口
|
||||
- 确认服务器防火墙已开放 Gateway 端口(默认 18790)
|
||||
- 确认 `corp_id`、`token`、`encoding_aes_key` 配置正确
|
||||
- 查看 PicoClaw 日志是否有请求到达
|
||||
|
||||
|
|
@ -78,7 +76,7 @@
|
|||
|
||||
**症状**: 启动时提示端口已被占用
|
||||
|
||||
**解决**: 修改 `webhook_port` 为其他端口,如 18794
|
||||
**解决**: 修改 `gateway.port` 为其他端口(所有 Webhook 渠道共享同一个 Gateway HTTP 服务器)
|
||||
|
||||
## 技术细节
|
||||
|
||||
|
|
|
|||
5
go.mod
5
go.mod
|
|
@ -33,13 +33,18 @@ require (
|
|||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/gdamore/tcell/v2 v2.13.8 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/tview v0.42.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||
|
|
|
|||
10
go.sum
10
go.sum
|
|
@ -50,6 +50,10 @@ github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L
|
|||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
||||
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||
|
|
@ -113,6 +117,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
|
|
@ -148,6 +154,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
|
||||
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
|
|
|
|||
|
|
@ -404,11 +404,11 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
|||
var wg sync.WaitGroup
|
||||
errs := make(chan string, goroutines*iterations)
|
||||
|
||||
for g := 0; g < goroutines; g++ {
|
||||
for g := range goroutines {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
for i := range iterations {
|
||||
result := cb.BuildSystemPromptWithCache()
|
||||
if result == "" {
|
||||
errs <- "empty prompt returned"
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ func registerSharedTools(
|
|||
}
|
||||
|
||||
// Web tools
|
||||
if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
||||
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
||||
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||
|
|
@ -239,7 +239,13 @@ func registerSharedTools(
|
|||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||
Proxy: cfg.Tools.Web.Proxy,
|
||||
}); searchTool != nil {
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{
|
||||
"agent_id": agentID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else if searchTool != nil {
|
||||
agent.Tools.Register(searchTool)
|
||||
logger.InfoCF("agent", "Web search provider registered", map[string]any{
|
||||
"agent_id": agentID,
|
||||
|
|
@ -250,7 +256,15 @@ func registerSharedTools(
|
|||
"agent_id": agentID,
|
||||
})
|
||||
}
|
||||
agent.Tools.Register(tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy))
|
||||
fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy)
|
||||
if err != nil {
|
||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{
|
||||
"agent_id": agentID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
agent.Tools.Register(fetchTool)
|
||||
}
|
||||
|
||||
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
||||
agent.Tools.Register(tools.NewI2CTool())
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
|
@ -190,13 +191,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
|
|||
toolsList := toolsInfo["names"].([]string)
|
||||
|
||||
// Check that our custom tool name is in the list
|
||||
found := false
|
||||
for _, name := range toolsList {
|
||||
if name == "mock_custom" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
found := slices.Contains(toolsList, "mock_custom")
|
||||
if !found {
|
||||
t.Error("Expected custom tool to be registered")
|
||||
}
|
||||
|
|
@ -265,13 +260,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
|
|||
toolsList := toolsInfo["names"].([]string)
|
||||
|
||||
// Check that our custom tool name is in the list
|
||||
found := false
|
||||
for _, name := range toolsList {
|
||||
if name == "mock_custom" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
found := slices.Contains(toolsList, "mock_custom")
|
||||
if !found {
|
||||
t.Error("Expected custom tool to be registered")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
|
|||
var sb strings.Builder
|
||||
first := true
|
||||
|
||||
for i := 0; i < days; i++ {
|
||||
for i := range days {
|
||||
date := time.Now().AddDate(0, 0, -i)
|
||||
dateStr := date.Format("20060102") // YYYYMMDD
|
||||
monthDir := dateStr[:6] // YYYYMM
|
||||
|
|
|
|||
|
|
@ -66,7 +66,8 @@ func decodeBase64(s string) string {
|
|||
return string(data)
|
||||
}
|
||||
|
||||
func generateState() (string, error) {
|
||||
// GenerateState generates a random state string for OAuth CSRF protection.
|
||||
func GenerateState() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
|
|
@ -80,7 +81,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||
return nil, fmt.Errorf("generating PKCE: %w", err)
|
||||
}
|
||||
|
||||
state, err := generateState()
|
||||
state, err := GenerateState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating state: %w", err)
|
||||
}
|
||||
|
|
@ -127,7 +128,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||
|
||||
fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL)
|
||||
|
||||
if err := openBrowser(authURL); err != nil {
|
||||
if err := OpenBrowser(authURL); err != nil {
|
||||
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
||||
}
|
||||
|
||||
|
|
@ -153,7 +154,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||
if result.err != nil {
|
||||
return nil, result.err
|
||||
}
|
||||
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
||||
return ExchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
||||
case manualInput := <-manualCh:
|
||||
if manualInput == "" {
|
||||
return nil, fmt.Errorf("manual input canceled")
|
||||
|
|
@ -169,7 +170,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||
if code == "" {
|
||||
return nil, fmt.Errorf("could not find authorization code in input")
|
||||
}
|
||||
return exchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
|
||||
return ExchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
|
||||
case <-time.After(5 * time.Minute):
|
||||
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
||||
}
|
||||
|
|
@ -186,6 +187,59 @@ type deviceCodeResponse struct {
|
|||
Interval int
|
||||
}
|
||||
|
||||
// DeviceCodeInfo holds the device code information returned by the OAuth provider.
|
||||
type DeviceCodeInfo struct {
|
||||
DeviceAuthID string `json:"device_auth_id"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerifyURL string `json:"verify_url"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
// RequestDeviceCode requests a device code from the OAuth provider.
|
||||
// Returns the info needed for the user to authenticate in a browser.
|
||||
func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) {
|
||||
reqBody, _ := json.Marshal(map[string]string{
|
||||
"client_id": cfg.ClientID,
|
||||
})
|
||||
|
||||
resp, err := http.Post(
|
||||
cfg.Issuer+"/api/accounts/deviceauth/usercode",
|
||||
"application/json",
|
||||
strings.NewReader(string(reqBody)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("requesting device code: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
||||
}
|
||||
|
||||
deviceResp, err := parseDeviceCodeResponse(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing device code response: %w", err)
|
||||
}
|
||||
|
||||
if deviceResp.Interval < 1 {
|
||||
deviceResp.Interval = 5
|
||||
}
|
||||
|
||||
return &DeviceCodeInfo{
|
||||
DeviceAuthID: deviceResp.DeviceAuthID,
|
||||
UserCode: deviceResp.UserCode,
|
||||
VerifyURL: cfg.Issuer + "/codex/device",
|
||||
Interval: deviceResp.Interval,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PollDeviceCodeOnce makes a single poll attempt to check if the user has authenticated.
|
||||
// Returns (credential, nil) on success, (nil, nil) if still pending, or (nil, err) on failure.
|
||||
func PollDeviceCodeOnce(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) {
|
||||
return pollDeviceCode(cfg, deviceAuthID, userCode)
|
||||
}
|
||||
|
||||
func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
|
||||
var raw struct {
|
||||
DeviceAuthID string `json:"device_auth_id"`
|
||||
|
|
@ -318,7 +372,7 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au
|
|||
}
|
||||
|
||||
redirectURI := cfg.Issuer + "/deviceauth/callback"
|
||||
return exchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
|
||||
return ExchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
|
||||
}
|
||||
|
||||
func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||
|
|
@ -410,7 +464,8 @@ func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU
|
|||
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
|
||||
}
|
||||
|
||||
func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
|
||||
// ExchangeCodeForTokens exchanges an authorization code for tokens.
|
||||
func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
|
||||
data := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
|
|
@ -552,7 +607,8 @@ func base64URLDecode(s string) ([]byte, error) {
|
|||
return base64.StdEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
func openBrowser(url string) error {
|
||||
// OpenBrowser opens the given URL in the user's default browser.
|
||||
func OpenBrowser(url string) error {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return exec.Command("open", url).Start()
|
||||
|
|
|
|||
|
|
@ -219,9 +219,9 @@ func TestExchangeCodeForTokens(t *testing.T) {
|
|||
Port: 1455,
|
||||
}
|
||||
|
||||
cred, err := exchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback")
|
||||
cred, err := ExchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback")
|
||||
if err != nil {
|
||||
t.Fatalf("exchangeCodeForTokens() error: %v", err)
|
||||
t.Fatalf("ExchangeCodeForTokens() error: %v", err)
|
||||
}
|
||||
|
||||
if cred.AccessToken != "mock-access-token" {
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ func TestPublishInbound_ContextCancel(t *testing.T) {
|
|||
|
||||
// Fill the buffer
|
||||
ctx := context.Background()
|
||||
for i := 0; i < defaultBusBufferSize; i++ {
|
||||
for i := range defaultBusBufferSize {
|
||||
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
|
||||
t.Fatalf("fill failed at %d: %v", i, err)
|
||||
}
|
||||
|
|
@ -154,7 +154,7 @@ func TestConcurrentPublishClose(t *testing.T) {
|
|||
wg.Add(numGoroutines + 1)
|
||||
|
||||
// Spawn many goroutines trying to publish
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
for range numGoroutines {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// Use a short timeout context so we don't block forever after close
|
||||
|
|
@ -194,7 +194,7 @@ func TestPublishInbound_FullBuffer(t *testing.T) {
|
|||
ctx := context.Background()
|
||||
|
||||
// Fill the buffer
|
||||
for i := 0; i < defaultBusBufferSize; i++ {
|
||||
for i := range defaultBusBufferSize {
|
||||
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
|
||||
t.Fatalf("fill failed at %d: %v", i, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
# PicoClaw Channel System Refactor: Complete Development Guide
|
||||
# PicoClaw Channel System: Complete Development Guide
|
||||
|
||||
> **Branch**: `refactor/channel-system`
|
||||
> **Status**: Active development (~40 commits)
|
||||
> **Scope**: `pkg/channels/`, `pkg/bus/`, `pkg/media/`, `pkg/identity/`, `cmd/picoclaw/internal/gateway/`
|
||||
|
||||
---
|
||||
|
|
@ -46,6 +44,8 @@ pkg/channels/
|
|||
pkg/channels/
|
||||
├── base.go # BaseChannel shared abstraction layer
|
||||
├── interfaces.go # Optional capability interfaces (TypingCapable, MessageEditor, ReactionCapable, PlaceholderCapable, PlaceholderRecorder)
|
||||
├── README.md # English documentation
|
||||
├── README.zh.md # Chinese documentation
|
||||
├── media.go # MediaSender optional interface
|
||||
├── webhook.go # WebhookHandler, HealthChecker optional interfaces
|
||||
├── errors.go # Sentinel errors (ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed)
|
||||
|
|
@ -60,7 +60,7 @@ pkg/channels/
|
|||
├── discord/
|
||||
│ ├── init.go
|
||||
│ └── discord.go
|
||||
├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ maixcam/ pico/
|
||||
├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/
|
||||
│ └── ...
|
||||
|
||||
pkg/bus/
|
||||
|
|
@ -111,7 +111,7 @@ pkg/identity/
|
|||
|-----------|-------------|
|
||||
| **Sub-package Isolation** | Each channel is a standalone Go sub-package, depending on `BaseChannel` and interfaces from the `channels` parent package |
|
||||
| **Factory Registration** | Sub-packages self-register via `init()`, Manager looks up factories by name, eliminating import coupling |
|
||||
| **Capability Discovery** | Optional capabilities are declared via interfaces (`MediaSender`, `TypingCapable`, `ReactionCapable`, `PlaceholderCapable`, `MessageEditor`, `WebhookHandler`), discovered by Manager via runtime type assertions |
|
||||
| **Capability Discovery** | Optional capabilities are declared via interfaces (`MediaSender`, `TypingCapable`, `ReactionCapable`, `PlaceholderCapable`, `MessageEditor`, `WebhookHandler`, `HealthChecker`), discovered by Manager via runtime type assertions |
|
||||
| **Structured Messages** | Peer, MessageID, and SenderInfo promoted from Metadata to first-class fields on InboundMessage |
|
||||
| **Error Classification** | Channels return sentinel errors (`ErrRateLimit`, `ErrTemporary`, etc.), Manager uses these to determine retry strategy |
|
||||
| **Centralized Orchestration** | Rate limiting, message splitting, retries, and Typing/Reaction/Placeholder management are all handled by Manager and BaseChannel; channels only need to implement Send |
|
||||
|
|
@ -145,6 +145,7 @@ After refactoring, these files have been removed and code moved to corresponding
|
|||
| _(did not exist)_ | `pkg/channels/interfaces.go` | New optional capability interfaces |
|
||||
| _(did not exist)_ | `pkg/channels/media.go` | New MediaSender interface |
|
||||
| _(did not exist)_ | `pkg/channels/webhook.go` | New WebhookHandler/HealthChecker |
|
||||
| _(did not exist)_ | `pkg/channels/whatsapp_native/` | New WhatsApp native mode (whatsmeow) |
|
||||
| _(did not exist)_ | `pkg/channels/split.go` | New message splitting (migrated from utils) |
|
||||
| _(did not exist)_ | `pkg/bus/types.go` | New structured message types |
|
||||
| _(did not exist)_ | `pkg/media/store.go` | New media file lifecycle management |
|
||||
|
|
@ -220,6 +221,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
|
|||
cfg.Channels.Telegram.AllowFrom, // Allow list
|
||||
channels.WithMaxMessageLength(4096), // Platform message length limit
|
||||
channels.WithGroupTrigger(cfg.Channels.Telegram.GroupTrigger), // Group trigger config
|
||||
channels.WithReasoningChannelID(cfg.Channels.Telegram.ReasoningChannelID), // Reasoning chain routing
|
||||
)
|
||||
return &TelegramChannel{
|
||||
BaseChannel: base,
|
||||
|
|
@ -466,6 +468,7 @@ func NewMatrixChannel(cfg *config.Config, msgBus *bus.MessageBus) (*MatrixChanne
|
|||
matrixCfg.AllowFrom, // Allow list
|
||||
channels.WithMaxMessageLength(65536), // Matrix message length limit
|
||||
channels.WithGroupTrigger(matrixCfg.GroupTrigger),
|
||||
channels.WithReasoningChannelID(matrixCfg.ReasoningChannelID), // Reasoning chain routing (optional)
|
||||
)
|
||||
|
||||
return &MatrixChannel{
|
||||
|
|
@ -666,6 +669,32 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID, messageID, cont
|
|||
}
|
||||
```
|
||||
|
||||
#### PlaceholderCapable — Placeholder Messages
|
||||
|
||||
```go
|
||||
// If the platform supports sending placeholder messages (e.g. "Thinking... 💭"),
|
||||
// and the channel also implements MessageEditor, then Manager's preSend will
|
||||
// automatically edit the placeholder into the final response on outbound.
|
||||
// SendPlaceholder checks PlaceholderConfig.Enabled internally;
|
||||
// returning ("", nil) means skip.
|
||||
func (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
||||
cfg := c.config.Channels.Matrix.Placeholder
|
||||
if !cfg.Enabled {
|
||||
return "", nil
|
||||
}
|
||||
text := cfg.Text
|
||||
if text == "" {
|
||||
text = "Thinking... 💭"
|
||||
}
|
||||
// Call Matrix API to send placeholder message
|
||||
msg, err := c.sendText(ctx, chatID, text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return msg.ID, nil
|
||||
}
|
||||
```
|
||||
|
||||
#### WebhookHandler — HTTP Webhook Reception
|
||||
|
||||
```go
|
||||
|
|
@ -746,15 +775,17 @@ When the Agent finishes processing a message, Manager's `preSend` automatically:
|
|||
```go
|
||||
type ChannelsConfig struct {
|
||||
// ... existing channels
|
||||
Matrix MatrixChannelConfig `yaml:"matrix" json:"matrix"`
|
||||
Matrix MatrixChannelConfig `json:"matrix"`
|
||||
}
|
||||
|
||||
type MatrixChannelConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
HomeServer string `yaml:"home_server" json:"home_server"`
|
||||
Token string `yaml:"token" json:"token"`
|
||||
AllowFrom []string `yaml:"allow_from" json:"allow_from"`
|
||||
GroupTrigger GroupTriggerConfig `yaml:"group_trigger" json:"group_trigger"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HomeServer string `json:"home_server"`
|
||||
Token string `json:"token"`
|
||||
AllowFrom []string `json:"allow_from"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id"`
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -767,6 +798,15 @@ if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" {
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: If your channel has multiple modes (like WhatsApp Bridge vs Native), branch in initChannels based on config:
|
||||
> ```go
|
||||
> if cfg.UseNative {
|
||||
> m.initChannel("whatsapp_native", "WhatsApp Native")
|
||||
> } else {
|
||||
> m.initChannel("whatsapp", "WhatsApp")
|
||||
> }
|
||||
> ```
|
||||
|
||||
#### Add blank import in Gateway
|
||||
|
||||
```go
|
||||
|
|
@ -882,19 +922,21 @@ BaseChannel is the shared abstraction layer for all channels, providing the foll
|
|||
| `IsRunning() bool` | Atomically read running state |
|
||||
| `SetRunning(bool)` | Atomically set running state |
|
||||
| `MaxMessageLength() int` | Message length limit (rune count), 0 = unlimited |
|
||||
| `ReasoningChannelID() string` | Reasoning chain routing target channel ID (empty = no routing) |
|
||||
| `IsAllowed(senderID string) bool` | Legacy allow-list check (supports `"id\|username"` and `"@username"` formats) |
|
||||
| `IsAllowedSender(sender SenderInfo) bool` | New allow-list check (delegates to `identity.MatchAllowed`) |
|
||||
| `ShouldRespondInGroup(isMentioned, content) (bool, string)` | Unified group chat trigger filtering logic |
|
||||
| `HandleMessage(...)` | Unified inbound message handling: permission check → build MediaScope → auto-trigger Typing/Reaction → publish to Bus |
|
||||
| `HandleMessage(...)` | Unified inbound message handling: permission check → build MediaScope → auto-trigger Typing/Reaction/Placeholder → publish to Bus |
|
||||
| `SetMediaStore(s) / GetMediaStore()` | MediaStore injected by Manager |
|
||||
| `SetPlaceholderRecorder(r) / GetPlaceholderRecorder()` | PlaceholderRecorder injected by Manager |
|
||||
| `SetOwner(ch)` | Concrete channel reference injected by Manager (used for Typing/Reaction type assertions in HandleMessage) |
|
||||
| `SetOwner(ch)` | Concrete channel reference injected by Manager (used for Typing/Reaction/Placeholder type assertions in HandleMessage) |
|
||||
|
||||
**Functional Options**:
|
||||
|
||||
```go
|
||||
channels.WithMaxMessageLength(4096) // Set platform message length limit
|
||||
channels.WithGroupTrigger(groupTriggerCfg) // Set group trigger configuration
|
||||
channels.WithReasoningChannelID(id) // Set reasoning chain routing target channel
|
||||
```
|
||||
|
||||
### 4.4 Factory Registry
|
||||
|
|
@ -998,7 +1040,7 @@ StartAll:
|
|||
- runMediaWorker (per-channel outbound media)
|
||||
- dispatchOutbound (route from bus to worker queues)
|
||||
- dispatchOutboundMedia (route from bus to media worker queues)
|
||||
- runTTLJanitor (every 10s clean up expired typing/placeholder)
|
||||
- runTTLJanitor (every 10s clean up expired typing/reaction/placeholder)
|
||||
4. Start shared HTTP server (if configured)
|
||||
|
||||
StopAll:
|
||||
|
|
@ -1206,18 +1248,20 @@ make test # Full test suite
|
|||
|
||||
| Sub-package | Registered Name | Optional Interfaces |
|
||||
|-------------|----------------|-------------------|
|
||||
| `pkg/channels/telegram/` | `"telegram"` | MessageEditor, MediaSender, TypingCapable, PlaceholderCapable |
|
||||
| `pkg/channels/discord/` | `"discord"` | MessageEditor, TypingCapable, PlaceholderCapable |
|
||||
| `pkg/channels/slack/` | `"slack"` | ReactionCapable |
|
||||
| `pkg/channels/line/` | `"line"` | WebhookHandler, HealthChecker, TypingCapable |
|
||||
| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable |
|
||||
| `pkg/channels/dingtalk/` | `"dingtalk"` | WebhookHandler |
|
||||
| `pkg/channels/feishu/` | `"feishu"` | WebhookHandler (architecture-specific build tags) |
|
||||
| `pkg/channels/wecom/` | `"wecom"` + `"wecom_app"` | WebhookHandler |
|
||||
| `pkg/channels/telegram/` | `"telegram"` | TypingCapable, PlaceholderCapable, MessageEditor, MediaSender |
|
||||
| `pkg/channels/discord/` | `"discord"` | TypingCapable, PlaceholderCapable, MessageEditor, MediaSender |
|
||||
| `pkg/channels/slack/` | `"slack"` | ReactionCapable, MediaSender |
|
||||
| `pkg/channels/line/` | `"line"` | TypingCapable, MediaSender, WebhookHandler |
|
||||
| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender |
|
||||
| `pkg/channels/dingtalk/` | `"dingtalk"` | — |
|
||||
| `pkg/channels/feishu/` | `"feishu"` | — (architecture-specific build tags: `feishu_32.go` / `feishu_64.go`) |
|
||||
| `pkg/channels/wecom/` | `"wecom"` | WebhookHandler, HealthChecker |
|
||||
| `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker |
|
||||
| `pkg/channels/qq/` | `"qq"` | — |
|
||||
| `pkg/channels/whatsapp/` | `"whatsapp"` | — |
|
||||
| `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge mode) |
|
||||
| `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (Native whatsmeow mode) |
|
||||
| `pkg/channels/maixcam/` | `"maixcam"` | — |
|
||||
| `pkg/channels/pico/` | `"pico"` | WebhookHandler (Pico Protocol), TypingCapable, PlaceholderCapable |
|
||||
| `pkg/channels/pico/` | `"pico"` | TypingCapable, PlaceholderCapable, MessageEditor, WebhookHandler |
|
||||
|
||||
### A.3 Interface Quick Reference
|
||||
|
||||
|
|
@ -1231,6 +1275,7 @@ type Channel interface {
|
|||
IsRunning() bool
|
||||
IsAllowed(senderID string) bool
|
||||
IsAllowedSender(sender bus.SenderInfo) bool
|
||||
ReasoningChannelID() string
|
||||
}
|
||||
|
||||
// ===== Optional =====
|
||||
|
|
@ -1324,8 +1369,16 @@ agentLoop.Stop() // Stop Agent
|
|||
|
||||
1. **Media cleanup temporarily disabled**: The `ReleaseAll` call in the Agent loop is commented out (`refactor(loop): disable media cleanup to prevent premature file deletion`) because session boundaries are not yet clearly defined. TTL cleanup remains active.
|
||||
|
||||
2. **Feishu architecture-specific compilation**: The Feishu channel uses build tags to distinguish 32-bit and 64-bit architectures (`feishu_32.go` / `feishu_64.go`).
|
||||
2. **Feishu architecture-specific compilation**: The Feishu channel uses build tags to distinguish 32-bit and 64-bit architectures (`feishu_32.go` / `feishu_64.go`). Feishu uses the SDK's WebSocket mode (not HTTP webhook), so it does not implement `WebhookHandler`.
|
||||
|
||||
3. **WeCom has two factories**: `"wecom"` (Bot mode) and `"wecom_app"` (App mode) are registered separately.
|
||||
3. **WeCom has two factories**: `"wecom"` (Bot mode, webhook only) and `"wecom_app"` (App mode, supports MediaSender) are registered separately. Both implement `WebhookHandler` and `HealthChecker`.
|
||||
|
||||
4. **Pico Protocol**: `pkg/channels/pico/` implements a custom PicoClaw native protocol channel that receives messages via webhook.
|
||||
4. **Pico Protocol**: `pkg/channels/pico/` implements a custom PicoClaw native protocol channel that receives messages via WebSocket webhook (`/pico/ws`).
|
||||
|
||||
5. **WhatsApp has two modes**: `"whatsapp"` (Bridge mode, communicates via external bridge URL) and `"whatsapp_native"` (native whatsmeow mode, connects directly to WhatsApp). Manager selects which to initialize based on `WhatsAppConfig.UseNative`.
|
||||
|
||||
6. **DingTalk uses Stream mode**: DingTalk uses the SDK's Stream/WebSocket mode (not HTTP webhook), so it does not implement `WebhookHandler`.
|
||||
|
||||
7. **PlaceholderConfig vs implementation**: `PlaceholderConfig` appears in 6 channel configs (Telegram, Discord, Slack, LINE, OneBot, Pico), but only channels that implement both `PlaceholderCapable` + `MessageEditor` (Telegram, Discord, Pico) can actually use placeholder message editing. The rest are reserved fields.
|
||||
|
||||
8. **ReasoningChannelID**: Most channel configs include a `reasoning_channel_id` field to route LLM reasoning/thinking output to a designated channel (WhatsApp, Telegram, Feishu, Discord, MaixCam, QQ, DingTalk, Slack, LINE, OneBot, WeCom, WeComApp). Note: `PicoConfig` does not currently expose this field. `BaseChannel` exposes this via the `WithReasoningChannelID` option and `ReasoningChannelID()` method.
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
# PicoClaw Channel System 重构:完整开发指南
|
||||
# PicoClaw Channel System:完整开发指南
|
||||
|
||||
> **分支**: `refactor/channel-system`
|
||||
> **状态**: 活跃开发中(约 40 commits)
|
||||
> **影响范围**: `pkg/channels/`, `pkg/bus/`, `pkg/media/`, `pkg/identity/`, `cmd/picoclaw/internal/gateway/`
|
||||
|
||||
---
|
||||
|
|
@ -46,6 +44,8 @@ pkg/channels/
|
|||
pkg/channels/
|
||||
├── base.go # BaseChannel 共享抽象层
|
||||
├── interfaces.go # 可选能力接口(TypingCapable, MessageEditor, ReactionCapable, PlaceholderCapable, PlaceholderRecorder)
|
||||
├── README.md # 英文文档
|
||||
├── README.zh.md # 中文文档
|
||||
├── media.go # MediaSender 可选接口
|
||||
├── webhook.go # WebhookHandler, HealthChecker 可选接口
|
||||
├── errors.go # 错误哨兵值(ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed)
|
||||
|
|
@ -60,7 +60,7 @@ pkg/channels/
|
|||
├── discord/
|
||||
│ ├── init.go
|
||||
│ └── discord.go
|
||||
├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ maixcam/ pico/
|
||||
├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/
|
||||
│ └── ...
|
||||
|
||||
pkg/bus/
|
||||
|
|
@ -111,7 +111,7 @@ pkg/identity/
|
|||
|------|------|
|
||||
| **子包隔离** | 每个 channel 一个独立 Go 子包,依赖 `channels` 父包提供的 `BaseChannel` 和接口 |
|
||||
| **工厂注册** | 各子包通过 `init()` 自注册,Manager 通过名字查找工厂,消除 import 耦合 |
|
||||
| **能力发现** | 可选能力通过接口(`MediaSender`, `TypingCapable`, `ReactionCapable`, `PlaceholderCapable`, `MessageEditor`, `WebhookHandler`)声明,Manager 运行时类型断言发现 |
|
||||
| **能力发现** | 可选能力通过接口(`MediaSender`, `TypingCapable`, `ReactionCapable`, `PlaceholderCapable`, `MessageEditor`, `WebhookHandler`, `HealthChecker`)声明,Manager 运行时类型断言发现 |
|
||||
| **结构化消息** | Peer、MessageID、SenderInfo 从 Metadata 提升为 InboundMessage 的一等字段 |
|
||||
| **错误分类** | Channel 返回哨兵错误(`ErrRateLimit`, `ErrTemporary` 等),Manager 据此决定重试策略 |
|
||||
| **集中编排** | 速率限制、消息分割、重试、Typing/Reaction/Placeholder 全部由 Manager 和 BaseChannel 统一处理,Channel 只负责 Send |
|
||||
|
|
@ -145,6 +145,7 @@ pkg/identity/
|
|||
| _(不存在)_ | `pkg/channels/interfaces.go` | 新增可选能力接口 |
|
||||
| _(不存在)_ | `pkg/channels/media.go` | 新增 MediaSender 接口 |
|
||||
| _(不存在)_ | `pkg/channels/webhook.go` | 新增 WebhookHandler/HealthChecker |
|
||||
| _(不存在)_ | `pkg/channels/whatsapp_native/` | 新增 WhatsApp 原生模式(whatsmeow) |
|
||||
| _(不存在)_ | `pkg/channels/split.go` | 新增消息分割(从 utils 迁入) |
|
||||
| _(不存在)_ | `pkg/bus/types.go` | 新增结构化消息类型 |
|
||||
| _(不存在)_ | `pkg/media/store.go` | 新增媒体文件生命周期管理 |
|
||||
|
|
@ -220,6 +221,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
|
|||
cfg.Channels.Telegram.AllowFrom, // 允许列表
|
||||
channels.WithMaxMessageLength(4096), // 平台消息长度上限
|
||||
channels.WithGroupTrigger(cfg.Channels.Telegram.GroupTrigger), // 群聊触发配置
|
||||
channels.WithReasoningChannelID(cfg.Channels.Telegram.ReasoningChannelID), // 思维链路由
|
||||
)
|
||||
return &TelegramChannel{
|
||||
BaseChannel: base,
|
||||
|
|
@ -466,6 +468,7 @@ func NewMatrixChannel(cfg *config.Config, msgBus *bus.MessageBus) (*MatrixChanne
|
|||
matrixCfg.AllowFrom, // 允许列表
|
||||
channels.WithMaxMessageLength(65536), // Matrix 消息长度限制
|
||||
channels.WithGroupTrigger(matrixCfg.GroupTrigger),
|
||||
channels.WithReasoningChannelID(matrixCfg.ReasoningChannelID), // 思维链路由(可选)
|
||||
)
|
||||
|
||||
return &MatrixChannel{
|
||||
|
|
@ -666,6 +669,31 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID, messageID, cont
|
|||
}
|
||||
```
|
||||
|
||||
#### PlaceholderCapable — 占位消息
|
||||
|
||||
```go
|
||||
// 如果平台支持发送占位消息(如 "Thinking... 💭"),并且实现了 MessageEditor,
|
||||
// 则 Manager 的 preSend 会在出站时自动将占位消息编辑为最终回复。
|
||||
// SendPlaceholder 内部根据 PlaceholderConfig.Enabled 决定是否发送;
|
||||
// 返回 ("", nil) 表示跳过。
|
||||
func (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
||||
cfg := c.config.Channels.Matrix.Placeholder
|
||||
if !cfg.Enabled {
|
||||
return "", nil
|
||||
}
|
||||
text := cfg.Text
|
||||
if text == "" {
|
||||
text = "Thinking... 💭"
|
||||
}
|
||||
// 调用 Matrix API 发送占位消息
|
||||
msg, err := c.sendText(ctx, chatID, text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return msg.ID, nil
|
||||
}
|
||||
```
|
||||
|
||||
#### WebhookHandler — HTTP Webhook 接收
|
||||
|
||||
```go
|
||||
|
|
@ -746,15 +774,17 @@ if c.owner != nil && c.placeholderRecorder != nil {
|
|||
```go
|
||||
type ChannelsConfig struct {
|
||||
// ... 现有 channels
|
||||
Matrix MatrixChannelConfig `yaml:"matrix" json:"matrix"`
|
||||
Matrix MatrixChannelConfig `json:"matrix"`
|
||||
}
|
||||
|
||||
type MatrixChannelConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
HomeServer string `yaml:"home_server" json:"home_server"`
|
||||
Token string `yaml:"token" json:"token"`
|
||||
AllowFrom []string `yaml:"allow_from" json:"allow_from"`
|
||||
GroupTrigger GroupTriggerConfig `yaml:"group_trigger" json:"group_trigger"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HomeServer string `json:"home_server"`
|
||||
Token string `json:"token"`
|
||||
AllowFrom []string `json:"allow_from"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id"`
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -767,6 +797,15 @@ if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" {
|
|||
}
|
||||
```
|
||||
|
||||
> **注意**:如果你的 channel 有多种模式(如 WhatsApp Bridge vs Native),需要在 initChannels 中根据配置分支:
|
||||
> ```go
|
||||
> if cfg.UseNative {
|
||||
> m.initChannel("whatsapp_native", "WhatsApp Native")
|
||||
> } else {
|
||||
> m.initChannel("whatsapp", "WhatsApp")
|
||||
> }
|
||||
> ```
|
||||
|
||||
#### 在 Gateway 中添加 blank import
|
||||
|
||||
```go
|
||||
|
|
@ -882,19 +921,21 @@ BaseChannel 是所有 channel 的共享抽象层,提供以下能力:
|
|||
| `IsRunning() bool` | 原子读取运行状态 |
|
||||
| `SetRunning(bool)` | 原子设置运行状态 |
|
||||
| `MaxMessageLength() int` | 消息长度限制(rune 计数),0 = 无限制 |
|
||||
| `ReasoningChannelID() string` | 思维链路由目标 channel ID(空 = 不路由) |
|
||||
| `IsAllowed(senderID string) bool` | 旧格式允许列表检查(支持 `"id\|username"` 和 `"@username"` 格式) |
|
||||
| `IsAllowedSender(sender SenderInfo) bool` | 新格式允许列表检查(委托给 `identity.MatchAllowed`) |
|
||||
| `ShouldRespondInGroup(isMentioned, content) (bool, string)` | 统一群聊触发过滤逻辑 |
|
||||
| `HandleMessage(...)` | 统一入站消息处理:权限检查 → 构建 MediaScope → 自动触发 Typing/Reaction → 发布到 Bus |
|
||||
| `HandleMessage(...)` | 统一入站消息处理:权限检查 → 构建 MediaScope → 自动触发 Typing/Reaction/Placeholder → 发布到 Bus |
|
||||
| `SetMediaStore(s) / GetMediaStore()` | Manager 注入的媒体存储 |
|
||||
| `SetPlaceholderRecorder(r) / GetPlaceholderRecorder()` | Manager 注入的占位符记录器 |
|
||||
| `SetOwner(ch) ` | Manager 注入的具体 channel 引用(用于 HandleMessage 内部的 Typing/Reaction 类型断言) |
|
||||
| `SetOwner(ch) ` | Manager 注入的具体 channel 引用(用于 HandleMessage 内部的 Typing/Reaction/Placeholder 类型断言) |
|
||||
|
||||
**功能选项**:
|
||||
|
||||
```go
|
||||
channels.WithMaxMessageLength(4096) // 设置平台消息长度限制
|
||||
channels.WithGroupTrigger(groupTriggerCfg) // 设置群聊触发配置
|
||||
channels.WithReasoningChannelID(id) // 设置思维链路由目标 channel
|
||||
```
|
||||
|
||||
### 4.4 工厂注册表
|
||||
|
|
@ -998,7 +1039,7 @@ StartAll:
|
|||
- runMediaWorker (per-channel 出站媒体)
|
||||
- dispatchOutbound (从 bus 路由到 worker 队列)
|
||||
- dispatchOutboundMedia (从 bus 路由到 media worker 队列)
|
||||
- runTTLJanitor (每 10s 清理过期 typing/placeholder)
|
||||
- runTTLJanitor (每 10s 清理过期 typing/reaction/placeholder)
|
||||
4. 启动共享 HTTP 服务器(如已配置)
|
||||
|
||||
StopAll:
|
||||
|
|
@ -1206,18 +1247,20 @@ make test # 全量测试
|
|||
|
||||
| 子包 | 注册名 | 可选接口 |
|
||||
|------|--------|----------|
|
||||
| `pkg/channels/telegram/` | `"telegram"` | MessageEditor, MediaSender, TypingCapable, PlaceholderCapable |
|
||||
| `pkg/channels/discord/` | `"discord"` | MessageEditor, TypingCapable, PlaceholderCapable |
|
||||
| `pkg/channels/slack/` | `"slack"` | ReactionCapable |
|
||||
| `pkg/channels/line/` | `"line"` | WebhookHandler, HealthChecker, TypingCapable |
|
||||
| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable |
|
||||
| `pkg/channels/dingtalk/` | `"dingtalk"` | WebhookHandler |
|
||||
| `pkg/channels/feishu/` | `"feishu"` | WebhookHandler (架构特定 build tags) |
|
||||
| `pkg/channels/wecom/` | `"wecom"` + `"wecom_app"` | WebhookHandler |
|
||||
| `pkg/channels/telegram/` | `"telegram"` | TypingCapable, PlaceholderCapable, MessageEditor, MediaSender |
|
||||
| `pkg/channels/discord/` | `"discord"` | TypingCapable, PlaceholderCapable, MessageEditor, MediaSender |
|
||||
| `pkg/channels/slack/` | `"slack"` | ReactionCapable, MediaSender |
|
||||
| `pkg/channels/line/` | `"line"` | TypingCapable, MediaSender, WebhookHandler |
|
||||
| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender |
|
||||
| `pkg/channels/dingtalk/` | `"dingtalk"` | — |
|
||||
| `pkg/channels/feishu/` | `"feishu"` | — (架构特定 build tags: `feishu_32.go` / `feishu_64.go`) |
|
||||
| `pkg/channels/wecom/` | `"wecom"` | WebhookHandler, HealthChecker |
|
||||
| `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker |
|
||||
| `pkg/channels/qq/` | `"qq"` | — |
|
||||
| `pkg/channels/whatsapp/` | `"whatsapp"` | — |
|
||||
| `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge 模式) |
|
||||
| `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (原生 whatsmeow 模式) |
|
||||
| `pkg/channels/maixcam/` | `"maixcam"` | — |
|
||||
| `pkg/channels/pico/` | `"pico"` | WebhookHandler (Pico Protocol), TypingCapable, PlaceholderCapable |
|
||||
| `pkg/channels/pico/` | `"pico"` | TypingCapable, PlaceholderCapable, MessageEditor, WebhookHandler |
|
||||
|
||||
### A.3 接口速查表
|
||||
|
||||
|
|
@ -1231,6 +1274,7 @@ type Channel interface {
|
|||
IsRunning() bool
|
||||
IsAllowed(senderID string) bool
|
||||
IsAllowedSender(sender bus.SenderInfo) bool
|
||||
ReasoningChannelID() string
|
||||
}
|
||||
|
||||
// ===== 可选实现 =====
|
||||
|
|
@ -1324,8 +1368,16 @@ agentLoop.Stop() // 停止 Agent
|
|||
|
||||
1. **媒体清理暂时禁用**:Agent loop 中的 `ReleaseAll` 调用被注释掉了(`refactor(loop): disable media cleanup to prevent premature file deletion`),因为会话边界尚未明确定义。TTL 清理仍然有效。
|
||||
|
||||
2. **Feishu 架构特定编译**:Feishu channel 使用 build tags 区分 32 位和 64 位架构(`feishu_32.go` / `feishu_64.go`)。
|
||||
2. **Feishu 架构特定编译**:Feishu channel 使用 build tags 区分 32 位和 64 位架构(`feishu_32.go` / `feishu_64.go`)。Feishu 使用 SDK 的 WebSocket 模式(非 HTTP webhook),因此不实现 `WebhookHandler`。
|
||||
|
||||
3. **WeCom 有两个工厂**:`"wecom"`(Bot 模式)和 `"wecom_app"`(应用模式)分别注册。
|
||||
3. **WeCom 有两个工厂**:`"wecom"`(Bot 模式,纯 webhook)和 `"wecom_app"`(应用模式,支持 MediaSender)分别注册。两者都实现了 `WebhookHandler` 和 `HealthChecker`。
|
||||
|
||||
4. **Pico Protocol**:`pkg/channels/pico/` 实现了一个自定义的 PicoClaw 原生协议 channel,通过 webhook 接收消息。
|
||||
4. **Pico Protocol**:`pkg/channels/pico/` 实现了一个自定义的 PicoClaw 原生协议 channel,通过 WebSocket webhook (`/pico/ws`) 接收消息。
|
||||
|
||||
5. **WhatsApp 有两种模式**:`"whatsapp"`(Bridge 模式,通过外部 bridge URL 通信)和 `"whatsapp_native"`(原生 whatsmeow 模式,直接连接 WhatsApp)。Manager 根据 `WhatsAppConfig.UseNative` 决定初始化哪个。
|
||||
|
||||
6. **DingTalk 使用 Stream 模式**:DingTalk 使用 SDK 的 Stream/WebSocket 模式(非 HTTP webhook),因此不实现 `WebhookHandler`。
|
||||
|
||||
7. **PlaceholderConfig 的配置与实现**:`PlaceholderConfig` 出现在 6 个 channel config 中(Telegram、Discord、Slack、LINE、OneBot、Pico),但只有实现了 `PlaceholderCapable` + `MessageEditor` 的 channel(Telegram、Discord、Pico)能真正使用占位消息编辑功能。其余 channel 的 `PlaceholderConfig` 为预留字段。
|
||||
|
||||
8. **ReasoningChannelID**:大多数 channel config 都包含 `reasoning_channel_id` 字段,用于将 LLM 的思维链(reasoning/thinking)路由到指定 channel(WhatsApp、Telegram、Feishu、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot、WeCom、WeComApp)。注意:`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。
|
||||
|
|
@ -255,6 +255,7 @@ func (c *DiscordChannel) SendWithID(ctx context.Context, chatID string, content
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// EditMessage implements channels.MessageEditor.
|
||||
func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
||||
_, err := c.session.ChannelMessageEdit(chatID, messageID, content)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ type MessageSenderWithID interface {
|
|||
SendWithID(ctx context.Context, chatID string, content string) (messageID string, err error)
|
||||
}
|
||||
|
||||
|
||||
// PlaceholderCapable — channels that can send a placeholder message
|
||||
// (e.g. "Thinking... 💭") that will later be edited to the actual response.
|
||||
// The channel MUST also implement MessageEditor for the placeholder to be useful.
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ type replyTokenEntry struct {
|
|||
type LINEChannel struct {
|
||||
*channels.BaseChannel
|
||||
config config.LINEConfig
|
||||
infoClient *http.Client // for bot info lookups (short timeout)
|
||||
apiClient *http.Client // for messaging API calls
|
||||
botUserID string // Bot's user ID
|
||||
botBasicID string // Bot's basic ID (e.g. @216ru...)
|
||||
botDisplayName string // Bot's display name for text-based mention detection
|
||||
|
|
@ -69,6 +71,8 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha
|
|||
return &LINEChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
infoClient: &http.Client{Timeout: 10 * time.Second},
|
||||
apiClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -104,8 +108,7 @@ func (c *LINEChannel) fetchBotInfo() error {
|
|||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := c.infoClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -644,8 +647,7 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any)
|
|||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := c.apiClient.Do(req)
|
||||
if err != nil {
|
||||
return channels.ClassifyNetError(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ type statusMsgEntry struct {
|
|||
createdAt time.Time
|
||||
}
|
||||
|
||||
|
||||
// channelRateConfig maps channel name to per-second rate limit.
|
||||
var channelRateConfig = map[string]float64{
|
||||
"telegram": 20,
|
||||
|
|
@ -558,6 +559,7 @@ func (m *Manager) handleTaskStatusSend(ctx context.Context, name string, w *chan
|
|||
_ = w.ch.Send(ctx, msg)
|
||||
}
|
||||
|
||||
|
||||
// sendWithRetry sends a message through the channel with rate limiting and
|
||||
// retry logic. It classifies errors to determine the retry strategy:
|
||||
// - ErrNotRunning / ErrSendFailed: permanent, no retry
|
||||
|
|
|
|||
|
|
@ -274,13 +274,12 @@ func TestWorkerRateLimiter(t *testing.T) {
|
|||
limiter: rate.NewLimiter(2, 1),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ctx := t.Context()
|
||||
|
||||
go m.runWorker(ctx, "test", w)
|
||||
|
||||
// Enqueue 4 messages
|
||||
for i := 0; i < 4; i++ {
|
||||
for i := range 4 {
|
||||
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: fmt.Sprintf("msg%d", i)}
|
||||
}
|
||||
|
||||
|
|
@ -352,8 +351,7 @@ func TestRunWorker_MessageSplitting(t *testing.T) {
|
|||
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ctx := t.Context()
|
||||
|
||||
go m.runWorker(ctx, "test", w)
|
||||
|
||||
|
|
@ -576,7 +574,7 @@ func TestRecordPlaceholder_ConcurrentSafe(t *testing.T) {
|
|||
m := newTestManager()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
for i := range 100 {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
|
|
@ -591,7 +589,7 @@ func TestRecordTypingStop_ConcurrentSafe(t *testing.T) {
|
|||
m := newTestManager()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
for i := range 100 {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
|
|
@ -834,7 +832,7 @@ func TestLazyWorkerCreation(t *testing.T) {
|
|||
func TestBuildMediaScope_FastIDUniqueness(t *testing.T) {
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for i := 0; i < 1000; i++ {
|
||||
for range 1000 {
|
||||
scope := BuildMediaScope("test", "chat1", "")
|
||||
if seen[scope] {
|
||||
t.Fatalf("duplicate scope generated: %s", scope)
|
||||
|
|
@ -975,7 +973,7 @@ func TestHandleStatusSend_SendsNewAndTracks(t *testing.T) {
|
|||
|
||||
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
|
||||
|
||||
// No placeholder, no tracked status → should use SendWithID
|
||||
// No placeholder, no tracked status -> should use SendWithID
|
||||
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "first status", IsStatus: true}
|
||||
m.handleStatusSend(context.Background(), "test", w, msg)
|
||||
|
||||
|
|
@ -1081,7 +1079,7 @@ func TestHandleTaskStatusSend_FallbackToSend(t *testing.T) {
|
|||
m := newTestManager()
|
||||
var sendCalled bool
|
||||
|
||||
// Channel without SendWithID — only has Send
|
||||
// Channel without SendWithID -- only has Send
|
||||
ch := &mockChannel{
|
||||
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
|
||||
sendCalled = true
|
||||
|
|
@ -1178,11 +1176,11 @@ func TestRunWorker_RoutesStatusMessages(t *testing.T) {
|
|||
|
||||
go m.runWorker(ctx, "test", w)
|
||||
|
||||
// Send a status message for chatID "1" (routed to handleStatusSend → SendWithID)
|
||||
// Send a status message for chatID "1" (routed to handleStatusSend -> SendWithID)
|
||||
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "status", IsStatus: true}
|
||||
// Send a task status message for chatID "2" (routed to handleTaskStatusSend → SendWithID)
|
||||
// Send a task status message for chatID "2" (routed to handleTaskStatusSend -> SendWithID)
|
||||
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "2", Content: "task", IsTaskStatus: true, TaskID: "t1"}
|
||||
// Send a regular message for chatID "3" (no tracked status → regular Send)
|
||||
// Send a regular message for chatID "3" (no tracked status -> regular Send)
|
||||
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "3", Content: "hello"}
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
|
|
|||
|
|
@ -337,10 +337,7 @@ func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.D
|
|||
}
|
||||
|
||||
func (c *OneBotChannel) reconnectLoop() {
|
||||
interval := time.Duration(c.config.ReconnectInterval) * time.Second
|
||||
if interval < 5*time.Second {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
interval := max(time.Duration(c.config.ReconnectInterval)*time.Second, 5*time.Second)
|
||||
|
||||
for {
|
||||
select {
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ func (c *PicoChannel) SendWithID(ctx context.Context, chatID string, content str
|
|||
return msgID, nil
|
||||
}
|
||||
|
||||
|
||||
// EditMessage implements channels.MessageEditor.
|
||||
func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
||||
outMsg := newMessage(TypeMessageUpdate, map[string]any{
|
||||
|
|
@ -312,8 +313,8 @@ func (c *PicoChannel) authenticate(r *http.Request) bool {
|
|||
|
||||
// Check Authorization header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
if strings.TrimPrefix(auth, "Bearer ") == token {
|
||||
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
|
||||
if after == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -318,6 +318,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
|||
|
||||
var mediaPaths []string
|
||||
|
||||
|
||||
scope := channels.BuildMediaScope("slack", chatID, messageTS)
|
||||
|
||||
// Helper to register a local file with the media store
|
||||
|
|
|
|||
|
|
@ -23,10 +23,7 @@ func SplitMessage(content string, maxLen int) []string {
|
|||
var messages []string
|
||||
|
||||
// Dynamic buffer: 10% of maxLen, but at least 50 chars if possible
|
||||
codeBlockBuffer := maxLen / 10
|
||||
if codeBlockBuffer < 50 {
|
||||
codeBlockBuffer = 50
|
||||
}
|
||||
codeBlockBuffer := max(maxLen/10, 50)
|
||||
if codeBlockBuffer > maxLen/2 {
|
||||
codeBlockBuffer = maxLen / 2
|
||||
}
|
||||
|
|
@ -40,10 +37,7 @@ func SplitMessage(content string, maxLen int) []string {
|
|||
}
|
||||
|
||||
// Effective split point: maxLen minus buffer, to leave room for code blocks
|
||||
effectiveLimit := maxLen - codeBlockBuffer
|
||||
if effectiveLimit < maxLen/2 {
|
||||
effectiveLimit = maxLen / 2
|
||||
}
|
||||
effectiveLimit := max(maxLen-codeBlockBuffer, maxLen/2)
|
||||
|
||||
end := start + effectiveLimit
|
||||
|
||||
|
|
@ -85,10 +79,9 @@ func SplitMessage(content string, maxLen int) []string {
|
|||
// If we have a reasonable amount of content after the header, split inside
|
||||
if msgEnd > headerEndIdx+20 {
|
||||
// Find a better split point closer to maxLen
|
||||
innerLimit := start + maxLen - 5 // Leave room for "\n```"
|
||||
if innerLimit > totalLen {
|
||||
innerLimit = totalLen
|
||||
}
|
||||
innerLimit := min(
|
||||
// Leave room for "\n```"
|
||||
start+maxLen-5, totalLen)
|
||||
betterEnd := findLastNewlineInRange(runes, start, innerLimit, 200)
|
||||
if betterEnd > headerEndIdx {
|
||||
msgEnd = betterEnd
|
||||
|
|
@ -117,10 +110,7 @@ func SplitMessage(content string, maxLen int) []string {
|
|||
if unclosedIdx-start > 20 {
|
||||
msgEnd = unclosedIdx
|
||||
} else {
|
||||
splitAt := start + maxLen - 5
|
||||
if splitAt > totalLen {
|
||||
splitAt = totalLen
|
||||
}
|
||||
splitAt := min(start+maxLen-5, totalLen)
|
||||
chunk := strings.TrimRight(string(runes[start:splitAt]), " \t\n\r") + "\n```"
|
||||
messages = append(messages, chunk)
|
||||
remaining := strings.TrimSpace(header + "\n" + string(runes[splitAt:totalLen]))
|
||||
|
|
@ -196,10 +186,7 @@ func findNewlineFrom(runes []rune, from int) int {
|
|||
// findLastNewlineInRange finds the last newline within the last searchWindow runes
|
||||
// of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found).
|
||||
func findLastNewlineInRange(runes []rune, start, end, searchWindow int) int {
|
||||
searchStart := end - searchWindow
|
||||
if searchStart < start {
|
||||
searchStart = start
|
||||
}
|
||||
searchStart := max(end-searchWindow, start)
|
||||
for i := end - 1; i >= searchStart; i-- {
|
||||
if runes[i] == '\n' {
|
||||
return i
|
||||
|
|
@ -211,10 +198,7 @@ func findLastNewlineInRange(runes []rune, start, end, searchWindow int) int {
|
|||
// findLastSpaceInRange finds the last space/tab within the last searchWindow runes
|
||||
// of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found).
|
||||
func findLastSpaceInRange(runes []rune, start, end, searchWindow int) int {
|
||||
searchStart := end - searchWindow
|
||||
if searchStart < start {
|
||||
searchStart = start
|
||||
}
|
||||
searchStart := max(end-searchWindow, start)
|
||||
for i := end - 1; i >= searchStart; i-- {
|
||||
if runes[i] == ' ' || runes[i] == '\t' {
|
||||
return i
|
||||
|
|
|
|||
|
|
@ -221,6 +221,7 @@ func (c *TelegramChannel) SendWithID(ctx context.Context, chatID string, content
|
|||
return fmt.Sprintf("%d", sent.MessageID), nil
|
||||
}
|
||||
|
||||
|
||||
// StartTyping implements channels.TypingCapable.
|
||||
// It sends ChatAction(typing) immediately and then repeats every 4 seconds
|
||||
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ const (
|
|||
type WeComAppChannel struct {
|
||||
*channels.BaseChannel
|
||||
config config.WeComAppConfig
|
||||
client *http.Client
|
||||
accessToken string
|
||||
tokenExpiry time.Time
|
||||
tokenMu sync.RWMutex
|
||||
|
|
@ -129,9 +130,20 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (
|
|||
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||
)
|
||||
|
||||
// Client timeout must be >= the configured ReplyTimeout so the
|
||||
// per-request context deadline is always the effective limit.
|
||||
clientTimeout := 30 * time.Second
|
||||
if d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout {
|
||||
clientTimeout = d
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &WeComAppChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
client: &http.Client{Timeout: clientTimeout},
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
processedMsgs: make(map[string]bool),
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -145,6 +157,10 @@ func (c *WeComAppChannel) Name() string {
|
|||
func (c *WeComAppChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("wecom_app", "Starting WeCom App channel...")
|
||||
|
||||
// Cancel the context created in the constructor to avoid a resource leak.
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
// Get initial access token
|
||||
|
|
@ -299,8 +315,7 @@ func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaTyp
|
|||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return "", channels.ClassifyNetError(err)
|
||||
}
|
||||
|
|
@ -357,8 +372,7 @@ func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, use
|
|||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return channels.ClassifyNetError(err)
|
||||
}
|
||||
|
|
@ -567,8 +581,9 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
|||
return
|
||||
}
|
||||
|
||||
// Process the message with context
|
||||
go c.processMessage(ctx, msg)
|
||||
// Process the message with the channel's long-lived context (not the HTTP
|
||||
// request context, which is canceled as soon as we return the response).
|
||||
go c.processMessage(c.ctx, msg)
|
||||
|
||||
// Return success response immediately
|
||||
// WeCom App requires response within configured timeout (default 5 seconds)
|
||||
|
|
@ -597,14 +612,14 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
|
|||
return
|
||||
}
|
||||
c.processedMsgs[msgID] = true
|
||||
c.msgMu.Unlock()
|
||||
|
||||
// Clean up old messages periodically (keep last 1000)
|
||||
// Clean up old messages while still holding the lock to avoid a data race
|
||||
// on len(). Reset the map but re-insert the current msgID so it remains
|
||||
// deduplicated.
|
||||
if len(c.processedMsgs) > 1000 {
|
||||
c.msgMu.Lock()
|
||||
c.processedMsgs = make(map[string]bool)
|
||||
c.msgMu.Unlock()
|
||||
c.processedMsgs[msgID] = true
|
||||
}
|
||||
c.msgMu.Unlock()
|
||||
|
||||
senderID := msg.FromUserName
|
||||
chatID := senderID // WeCom App uses user ID as chat ID for direct messages
|
||||
|
|
@ -738,8 +753,7 @@ func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, user
|
|||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return channels.ClassifyNetError(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ func encryptTestMessageApp(message, aesKey string) (string, error) {
|
|||
|
||||
// Prepare message: random(16) + msg_len(4) + msg + corp_id
|
||||
random := make([]byte, 0, 16)
|
||||
for i := 0; i < 16; i++ {
|
||||
for i := range 16 {
|
||||
random = append(random, byte(i+1))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import (
|
|||
type WeComBotChannel struct {
|
||||
*channels.BaseChannel
|
||||
config config.WeComConfig
|
||||
client *http.Client
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
|
||||
|
|
@ -93,9 +94,20 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We
|
|||
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||
)
|
||||
|
||||
// Client timeout must be >= the configured ReplyTimeout so the
|
||||
// per-request context deadline is always the effective limit.
|
||||
clientTimeout := 30 * time.Second
|
||||
if d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout {
|
||||
clientTimeout = d
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &WeComBotChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
client: &http.Client{Timeout: clientTimeout},
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
processedMsgs: make(map[string]bool),
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -109,6 +121,10 @@ func (c *WeComBotChannel) Name() string {
|
|||
func (c *WeComBotChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("wecom", "Starting WeCom Bot channel...")
|
||||
|
||||
// Cancel the context created in the constructor to avoid a resource leak.
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
c.SetRunning(true)
|
||||
|
|
@ -292,8 +308,9 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
|||
return
|
||||
}
|
||||
|
||||
// Process the message asynchronously with context
|
||||
go c.processMessage(ctx, msg)
|
||||
// Process the message with the channel's long-lived context (not the HTTP
|
||||
// request context, which is canceled as soon as we return the response).
|
||||
go c.processMessage(c.ctx, msg)
|
||||
|
||||
// Return success response immediately
|
||||
// WeCom Bot requires response within configured timeout (default 5 seconds)
|
||||
|
|
@ -322,9 +339,12 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
|
|||
return
|
||||
}
|
||||
c.processedMsgs[msgID] = true
|
||||
// Clean up old messages periodically (keep last 1000)
|
||||
// Clean up old messages while still holding the lock to avoid a data race
|
||||
// on len(). Reset the map but re-insert the current msgID so it remains
|
||||
// deduplicated.
|
||||
if len(c.processedMsgs) > 1000 {
|
||||
c.processedMsgs = make(map[string]bool)
|
||||
c.processedMsgs[msgID] = true
|
||||
}
|
||||
c.msgMu.Unlock()
|
||||
|
||||
|
|
@ -439,8 +459,7 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content
|
|||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return channels.ClassifyNetError(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ func encryptTestMessage(message, aesKey string) (string, error) {
|
|||
|
||||
// Prepare message: random(16) + msg_len(4) + msg + receiveid
|
||||
random := make([]byte, 0, 16)
|
||||
for i := 0; i < 16; i++ {
|
||||
for i := range 16 {
|
||||
random = append(random, byte(i))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ func pkcs7Unpad(data []byte) ([]byte, error) {
|
|||
return nil, fmt.Errorf("padding size larger than data")
|
||||
}
|
||||
// Verify all padding bytes
|
||||
for i := 0; i < padding; i++ {
|
||||
for i := range padding {
|
||||
if data[len(data)-1-i] != byte(padding) {
|
||||
return nil, fmt.Errorf("invalid padding byte at position %d", i)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ func TestGetModelConfig_RoundRobin(t *testing.T) {
|
|||
|
||||
// Test round-robin distribution
|
||||
results := make(map[string]int)
|
||||
for i := 0; i < 30; i++ {
|
||||
for range 30 {
|
||||
result, err := cfg.GetModelConfig("lb-model")
|
||||
if err != nil {
|
||||
t.Fatalf("GetModelConfig() error = %v", err)
|
||||
|
|
@ -94,17 +94,15 @@ func TestGetModelConfig_Concurrent(t *testing.T) {
|
|||
var wg sync.WaitGroup
|
||||
errors := make(chan error, goroutines*iterations)
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
for range goroutines {
|
||||
wg.Go(func() {
|
||||
for range iterations {
|
||||
_, err := cfg.GetModelConfig("concurrent-model")
|
||||
if err != nil {
|
||||
errors <- err
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -146,9 +147,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
|
|||
s.mu.RLock()
|
||||
ready := s.ready
|
||||
checks := make(map[string]Check)
|
||||
for k, v := range s.checks {
|
||||
checks[k] = v
|
||||
}
|
||||
maps.Copy(checks, s.checks)
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ready {
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ func TestReleaseAll(t *testing.T) {
|
|||
|
||||
paths := make([]string, 3)
|
||||
refs := make([]string, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
for i := range 3 {
|
||||
paths[i] = createTempFile(t, dir, strings.Repeat("a", i+1)+".jpg")
|
||||
var err error
|
||||
refs[i], err = store.Store(paths[i], MediaMeta{Source: "test"}, "scope1")
|
||||
|
|
@ -228,12 +228,12 @@ func TestConcurrentSafety(t *testing.T) {
|
|||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
|
||||
for g := 0; g < goroutines; g++ {
|
||||
for g := range goroutines {
|
||||
go func(gIdx int) {
|
||||
defer wg.Done()
|
||||
scope := strings.Repeat("s", gIdx+1)
|
||||
|
||||
for i := 0; i < filesPerGoroutine; i++ {
|
||||
for i := range filesPerGoroutine {
|
||||
path := createTempFile(t, dir, strings.Repeat("f", gIdx*filesPerGoroutine+i+1)+".tmp")
|
||||
ref, err := store.Store(path, MediaMeta{Source: "test"}, scope)
|
||||
if err != nil {
|
||||
|
|
@ -448,11 +448,11 @@ func TestConcurrentCleanupSafety(t *testing.T) {
|
|||
wg.Add(workers * 4)
|
||||
|
||||
// Store workers
|
||||
for w := 0; w < workers; w++ {
|
||||
for w := range workers {
|
||||
go func(wIdx int) {
|
||||
defer wg.Done()
|
||||
scope := fmt.Sprintf("scope-%d", wIdx)
|
||||
for i := 0; i < ops; i++ {
|
||||
for i := range ops {
|
||||
p := createTempFile(t, dir, fmt.Sprintf("w%d-f%d.tmp", wIdx, i))
|
||||
store.Store(p, MediaMeta{Source: "test"}, scope)
|
||||
}
|
||||
|
|
@ -460,30 +460,30 @@ func TestConcurrentCleanupSafety(t *testing.T) {
|
|||
}
|
||||
|
||||
// Resolve workers
|
||||
for w := 0; w < workers; w++ {
|
||||
for range workers {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < ops; i++ {
|
||||
for range ops {
|
||||
store.Resolve("media://nonexistent")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ReleaseAll workers
|
||||
for w := 0; w < workers; w++ {
|
||||
for w := range workers {
|
||||
go func(wIdx int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < ops; i++ {
|
||||
for range ops {
|
||||
store.ReleaseAll(fmt.Sprintf("scope-%d", wIdx))
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
// CleanExpired workers
|
||||
for w := 0; w < workers; w++ {
|
||||
for range workers {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < ops; i++ {
|
||||
for range ops {
|
||||
store.CleanExpired()
|
||||
}
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -1,414 +0,0 @@
|
|||
package migrate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
var supportedProviders = map[string]bool{
|
||||
"anthropic": true,
|
||||
"openai": true,
|
||||
"openrouter": true,
|
||||
"groq": true,
|
||||
"zhipu": true,
|
||||
"vllm": true,
|
||||
"gemini": true,
|
||||
"qwen": true,
|
||||
"deepseek": true,
|
||||
"github_copilot": true,
|
||||
"mistral": true,
|
||||
}
|
||||
|
||||
var supportedChannels = map[string]bool{
|
||||
"telegram": true,
|
||||
"discord": true,
|
||||
"whatsapp": true,
|
||||
"feishu": true,
|
||||
"qq": true,
|
||||
"dingtalk": true,
|
||||
"maixcam": true,
|
||||
}
|
||||
|
||||
func findOpenClawConfig(openclawHome string) (string, error) {
|
||||
candidates := []string{
|
||||
filepath.Join(openclawHome, "openclaw.json"),
|
||||
filepath.Join(openclawHome, "config.json"),
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", openclawHome)
|
||||
}
|
||||
|
||||
func LoadOpenClawConfig(configPath string) (map[string]any, error) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading OpenClaw config: %w", err)
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parsing OpenClaw config: %w", err)
|
||||
}
|
||||
|
||||
converted := convertKeysToSnake(raw)
|
||||
result, ok := converted.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected config format")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
|
||||
cfg := config.DefaultConfig()
|
||||
var warnings []string
|
||||
|
||||
if agents, ok := getMap(data, "agents"); ok {
|
||||
if defaults, ok := getMap(agents, "defaults"); ok {
|
||||
// Prefer model_name, fallback to model for backward compatibility
|
||||
if v, ok := getString(defaults, "model_name"); ok {
|
||||
cfg.Agents.Defaults.ModelName = v
|
||||
} else if v, ok := getString(defaults, "model"); ok {
|
||||
cfg.Agents.Defaults.Model = v
|
||||
}
|
||||
if v, ok := getFloat(defaults, "max_tokens"); ok {
|
||||
cfg.Agents.Defaults.MaxTokens = int(v)
|
||||
}
|
||||
if v, ok := getFloat(defaults, "temperature"); ok {
|
||||
cfg.Agents.Defaults.Temperature = &v
|
||||
}
|
||||
if v, ok := getFloat(defaults, "max_tool_iterations"); ok {
|
||||
cfg.Agents.Defaults.MaxToolIterations = int(v)
|
||||
}
|
||||
if v, ok := getString(defaults, "workspace"); ok {
|
||||
cfg.Agents.Defaults.Workspace = rewriteWorkspacePath(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if providers, ok := getMap(data, "providers"); ok {
|
||||
for name, val := range providers {
|
||||
pMap, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
apiKey, _ := getString(pMap, "api_key")
|
||||
apiBase, _ := getString(pMap, "api_base")
|
||||
|
||||
if !supportedProviders[name] {
|
||||
if apiKey != "" || apiBase != "" {
|
||||
warnings = append(warnings, fmt.Sprintf("Provider '%s' not supported in PicoClaw, skipping", name))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
pc := config.ProviderConfig{APIKey: apiKey, APIBase: apiBase}
|
||||
switch name {
|
||||
case "anthropic":
|
||||
cfg.Providers.Anthropic = pc
|
||||
case "openai":
|
||||
cfg.Providers.OpenAI = config.OpenAIProviderConfig{
|
||||
ProviderConfig: pc,
|
||||
WebSearch: getBoolOrDefault(pMap, "web_search", true),
|
||||
}
|
||||
case "openrouter":
|
||||
cfg.Providers.OpenRouter = pc
|
||||
case "groq":
|
||||
cfg.Providers.Groq = pc
|
||||
case "zhipu":
|
||||
cfg.Providers.Zhipu = pc
|
||||
case "vllm":
|
||||
cfg.Providers.VLLM = pc
|
||||
case "gemini":
|
||||
cfg.Providers.Gemini = pc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if channels, ok := getMap(data, "channels"); ok {
|
||||
for name, val := range channels {
|
||||
cMap, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !supportedChannels[name] {
|
||||
warnings = append(warnings, fmt.Sprintf("Channel '%s' not supported in PicoClaw, skipping", name))
|
||||
continue
|
||||
}
|
||||
enabled, _ := getBool(cMap, "enabled")
|
||||
allowFrom := getStringSlice(cMap, "allow_from")
|
||||
|
||||
switch name {
|
||||
case "telegram":
|
||||
cfg.Channels.Telegram.Enabled = enabled
|
||||
cfg.Channels.Telegram.AllowFrom = allowFrom
|
||||
if v, ok := getString(cMap, "token"); ok {
|
||||
cfg.Channels.Telegram.Token = v
|
||||
}
|
||||
case "discord":
|
||||
cfg.Channels.Discord.Enabled = enabled
|
||||
cfg.Channels.Discord.AllowFrom = allowFrom
|
||||
if v, ok := getString(cMap, "token"); ok {
|
||||
cfg.Channels.Discord.Token = v
|
||||
}
|
||||
case "whatsapp":
|
||||
cfg.Channels.WhatsApp.Enabled = enabled
|
||||
cfg.Channels.WhatsApp.AllowFrom = allowFrom
|
||||
if v, ok := getString(cMap, "bridge_url"); ok {
|
||||
cfg.Channels.WhatsApp.BridgeURL = v
|
||||
}
|
||||
if v, ok := getBool(cMap, "use_native"); ok {
|
||||
cfg.Channels.WhatsApp.UseNative = v
|
||||
}
|
||||
if v, ok := getString(cMap, "session_store_path"); ok {
|
||||
cfg.Channels.WhatsApp.SessionStorePath = v
|
||||
}
|
||||
case "feishu":
|
||||
cfg.Channels.Feishu.Enabled = enabled
|
||||
cfg.Channels.Feishu.AllowFrom = allowFrom
|
||||
if v, ok := getString(cMap, "app_id"); ok {
|
||||
cfg.Channels.Feishu.AppID = v
|
||||
}
|
||||
if v, ok := getString(cMap, "app_secret"); ok {
|
||||
cfg.Channels.Feishu.AppSecret = v
|
||||
}
|
||||
if v, ok := getString(cMap, "encrypt_key"); ok {
|
||||
cfg.Channels.Feishu.EncryptKey = v
|
||||
}
|
||||
if v, ok := getString(cMap, "verification_token"); ok {
|
||||
cfg.Channels.Feishu.VerificationToken = v
|
||||
}
|
||||
case "qq":
|
||||
cfg.Channels.QQ.Enabled = enabled
|
||||
cfg.Channels.QQ.AllowFrom = allowFrom
|
||||
if v, ok := getString(cMap, "app_id"); ok {
|
||||
cfg.Channels.QQ.AppID = v
|
||||
}
|
||||
if v, ok := getString(cMap, "app_secret"); ok {
|
||||
cfg.Channels.QQ.AppSecret = v
|
||||
}
|
||||
case "dingtalk":
|
||||
cfg.Channels.DingTalk.Enabled = enabled
|
||||
cfg.Channels.DingTalk.AllowFrom = allowFrom
|
||||
if v, ok := getString(cMap, "client_id"); ok {
|
||||
cfg.Channels.DingTalk.ClientID = v
|
||||
}
|
||||
if v, ok := getString(cMap, "client_secret"); ok {
|
||||
cfg.Channels.DingTalk.ClientSecret = v
|
||||
}
|
||||
case "maixcam":
|
||||
cfg.Channels.MaixCam.Enabled = enabled
|
||||
cfg.Channels.MaixCam.AllowFrom = allowFrom
|
||||
if v, ok := getString(cMap, "host"); ok {
|
||||
cfg.Channels.MaixCam.Host = v
|
||||
}
|
||||
if v, ok := getFloat(cMap, "port"); ok {
|
||||
cfg.Channels.MaixCam.Port = int(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if gateway, ok := getMap(data, "gateway"); ok {
|
||||
if v, ok := getString(gateway, "host"); ok {
|
||||
cfg.Gateway.Host = v
|
||||
}
|
||||
if v, ok := getFloat(gateway, "port"); ok {
|
||||
cfg.Gateway.Port = int(v)
|
||||
}
|
||||
}
|
||||
|
||||
if tools, ok := getMap(data, "tools"); ok {
|
||||
if web, ok := getMap(tools, "web"); ok {
|
||||
// Migrate old "search" config to "brave" if api_key is present
|
||||
if search, ok := getMap(web, "search"); ok {
|
||||
if v, ok := getString(search, "api_key"); ok {
|
||||
cfg.Tools.Web.Brave.APIKey = v
|
||||
if v != "" {
|
||||
cfg.Tools.Web.Brave.Enabled = true
|
||||
}
|
||||
}
|
||||
if v, ok := getFloat(search, "max_results"); ok {
|
||||
cfg.Tools.Web.Brave.MaxResults = int(v)
|
||||
cfg.Tools.Web.DuckDuckGo.MaxResults = int(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, warnings, nil
|
||||
}
|
||||
|
||||
func MergeConfig(existing, incoming *config.Config) *config.Config {
|
||||
if existing.Providers.Anthropic.APIKey == "" {
|
||||
existing.Providers.Anthropic = incoming.Providers.Anthropic
|
||||
}
|
||||
if existing.Providers.OpenAI.APIKey == "" {
|
||||
existing.Providers.OpenAI = incoming.Providers.OpenAI
|
||||
}
|
||||
if existing.Providers.OpenRouter.APIKey == "" {
|
||||
existing.Providers.OpenRouter = incoming.Providers.OpenRouter
|
||||
}
|
||||
if existing.Providers.Groq.APIKey == "" {
|
||||
existing.Providers.Groq = incoming.Providers.Groq
|
||||
}
|
||||
if existing.Providers.Zhipu.APIKey == "" {
|
||||
existing.Providers.Zhipu = incoming.Providers.Zhipu
|
||||
}
|
||||
if existing.Providers.VLLM.APIKey == "" && existing.Providers.VLLM.APIBase == "" {
|
||||
existing.Providers.VLLM = incoming.Providers.VLLM
|
||||
}
|
||||
if existing.Providers.Gemini.APIKey == "" {
|
||||
existing.Providers.Gemini = incoming.Providers.Gemini
|
||||
}
|
||||
if existing.Providers.DeepSeek.APIKey == "" {
|
||||
existing.Providers.DeepSeek = incoming.Providers.DeepSeek
|
||||
}
|
||||
if existing.Providers.GitHubCopilot.APIBase == "" {
|
||||
existing.Providers.GitHubCopilot = incoming.Providers.GitHubCopilot
|
||||
}
|
||||
if existing.Providers.Qwen.APIKey == "" {
|
||||
existing.Providers.Qwen = incoming.Providers.Qwen
|
||||
}
|
||||
|
||||
if !existing.Channels.Telegram.Enabled && incoming.Channels.Telegram.Enabled {
|
||||
existing.Channels.Telegram = incoming.Channels.Telegram
|
||||
}
|
||||
if !existing.Channels.Discord.Enabled && incoming.Channels.Discord.Enabled {
|
||||
existing.Channels.Discord = incoming.Channels.Discord
|
||||
}
|
||||
if !existing.Channels.WhatsApp.Enabled && incoming.Channels.WhatsApp.Enabled {
|
||||
existing.Channels.WhatsApp = incoming.Channels.WhatsApp
|
||||
}
|
||||
if !existing.Channels.Feishu.Enabled && incoming.Channels.Feishu.Enabled {
|
||||
existing.Channels.Feishu = incoming.Channels.Feishu
|
||||
}
|
||||
if !existing.Channels.QQ.Enabled && incoming.Channels.QQ.Enabled {
|
||||
existing.Channels.QQ = incoming.Channels.QQ
|
||||
}
|
||||
if !existing.Channels.DingTalk.Enabled && incoming.Channels.DingTalk.Enabled {
|
||||
existing.Channels.DingTalk = incoming.Channels.DingTalk
|
||||
}
|
||||
if !existing.Channels.MaixCam.Enabled && incoming.Channels.MaixCam.Enabled {
|
||||
existing.Channels.MaixCam = incoming.Channels.MaixCam
|
||||
}
|
||||
|
||||
if existing.Tools.Web.Brave.APIKey == "" {
|
||||
existing.Tools.Web.Brave = incoming.Tools.Web.Brave
|
||||
}
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
func camelToSnake(s string) string {
|
||||
var result strings.Builder
|
||||
for i, r := range s {
|
||||
if unicode.IsUpper(r) {
|
||||
if i > 0 {
|
||||
prev := rune(s[i-1])
|
||||
if unicode.IsLower(prev) || unicode.IsDigit(prev) {
|
||||
result.WriteRune('_')
|
||||
} else if unicode.IsUpper(prev) && i+1 < len(s) && unicode.IsLower(rune(s[i+1])) {
|
||||
result.WriteRune('_')
|
||||
}
|
||||
}
|
||||
result.WriteRune(unicode.ToLower(r))
|
||||
} else {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func convertKeysToSnake(data any) any {
|
||||
switch v := data.(type) {
|
||||
case map[string]any:
|
||||
result := make(map[string]any, len(v))
|
||||
for key, val := range v {
|
||||
result[camelToSnake(key)] = convertKeysToSnake(val)
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]any, len(v))
|
||||
for i, val := range v {
|
||||
result[i] = convertKeysToSnake(val)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
func rewriteWorkspacePath(path string) string {
|
||||
path = strings.Replace(path, ".openclaw", ".picoclaw", 1)
|
||||
return path
|
||||
}
|
||||
|
||||
func getMap(data map[string]any, key string) (map[string]any, bool) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
m, ok := v.(map[string]any)
|
||||
return m, ok
|
||||
}
|
||||
|
||||
func getString(data map[string]any, key string) (string, bool) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
s, ok := v.(string)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func getFloat(data map[string]any, key string) (float64, bool) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
return f, ok
|
||||
}
|
||||
|
||||
func getBool(data map[string]any, key string) (bool, bool) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
return b, ok
|
||||
}
|
||||
|
||||
func getBoolOrDefault(data map[string]any, key string, defaultVal bool) bool {
|
||||
if v, ok := getBool(data, key); ok {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getStringSlice(data map[string]any, key string) []string {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return []string{}
|
||||
}
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return []string{}
|
||||
}
|
||||
result := make([]string, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -1,24 +1,50 @@
|
|||
package migrate
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var migrateableFiles = []string{
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"USER.md",
|
||||
"TOOLS.md",
|
||||
"HEARTBEAT.md",
|
||||
func ResolveTargetHome(override string) (string, error) {
|
||||
if override != "" {
|
||||
return ExpandHome(override), nil
|
||||
}
|
||||
if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" {
|
||||
return ExpandHome(envHome), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw"), nil
|
||||
}
|
||||
|
||||
var migrateableDirs = []string{
|
||||
"memory",
|
||||
"skills",
|
||||
func ExpandHome(path string) string {
|
||||
if path == "" {
|
||||
return path
|
||||
}
|
||||
if path[0] == '~' {
|
||||
home, _ := os.UserHomeDir()
|
||||
if len(path) > 1 && path[1] == '/' {
|
||||
return home + path[1:]
|
||||
}
|
||||
return home
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func PlanWorkspaceMigration(srcWorkspace, dstWorkspace string, force bool) ([]Action, error) {
|
||||
func ResolveWorkspace(homeDir string) string {
|
||||
return filepath.Join(homeDir, "workspace")
|
||||
}
|
||||
|
||||
func PlanWorkspaceMigration(
|
||||
srcWorkspace, dstWorkspace string,
|
||||
migrateableFiles []string,
|
||||
migrateableDirs []string,
|
||||
force bool,
|
||||
) ([]Action, error) {
|
||||
var actions []Action
|
||||
|
||||
for _, filename := range migrateableFiles {
|
||||
|
|
@ -50,7 +76,7 @@ func planFileCopy(src, dst string, force bool) Action {
|
|||
return Action{
|
||||
Type: ActionSkip,
|
||||
Source: src,
|
||||
Destination: dst,
|
||||
Target: dst,
|
||||
Description: "source file not found",
|
||||
}
|
||||
}
|
||||
|
|
@ -60,7 +86,7 @@ func planFileCopy(src, dst string, force bool) Action {
|
|||
return Action{
|
||||
Type: ActionBackup,
|
||||
Source: src,
|
||||
Destination: dst,
|
||||
Target: dst,
|
||||
Description: "destination exists, will backup and overwrite",
|
||||
}
|
||||
}
|
||||
|
|
@ -68,7 +94,7 @@ func planFileCopy(src, dst string, force bool) Action {
|
|||
return Action{
|
||||
Type: ActionCopy,
|
||||
Source: src,
|
||||
Destination: dst,
|
||||
Target: dst,
|
||||
Description: "copy file",
|
||||
}
|
||||
}
|
||||
|
|
@ -91,7 +117,7 @@ func planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) {
|
|||
if info.IsDir() {
|
||||
actions = append(actions, Action{
|
||||
Type: ActionCreateDir,
|
||||
Destination: dst,
|
||||
Target: dst,
|
||||
Description: "create directory",
|
||||
})
|
||||
return nil
|
||||
|
|
@ -104,3 +130,33 @@ func planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) {
|
|||
|
||||
return actions, err
|
||||
}
|
||||
|
||||
func RelPath(path, base string) string {
|
||||
rel, err := filepath.Rel(base, path)
|
||||
if err != nil {
|
||||
return filepath.Base(path)
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
func CopyFile(src, dst string) error {
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
info, err := srcFile.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
}
|
||||
195
pkg/migrate/internal/common_test.go
Normal file
195
pkg/migrate/internal/common_test.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExpandHome(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"", ""},
|
||||
{"/absolute/path", "/absolute/path"},
|
||||
{"relative/path", "relative/path"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := ExpandHome(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandHomeWithTilde(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
|
||||
result := ExpandHome("~/path")
|
||||
assert.Equal(t, home+"/path", result)
|
||||
|
||||
result = ExpandHome("~")
|
||||
assert.Equal(t, home, result)
|
||||
}
|
||||
|
||||
func TestResolveWorkspace(t *testing.T) {
|
||||
result := ResolveWorkspace("/home/user/.picoclaw")
|
||||
assert.Equal(t, "/home/user/.picoclaw/workspace", result)
|
||||
}
|
||||
|
||||
func TestRelPath(t *testing.T) {
|
||||
result := RelPath("/home/user/.picoclaw/workspace/file.txt", "/home/user/.picoclaw")
|
||||
assert.Equal(t, "workspace/file.txt", result)
|
||||
}
|
||||
|
||||
func TestRelPathError(t *testing.T) {
|
||||
result := RelPath("relative/path", "/different/base")
|
||||
assert.Equal(t, "path", result)
|
||||
}
|
||||
|
||||
func TestResolveTargetHome(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ResolveTargetHome("")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(home, ".picoclaw"), result)
|
||||
}
|
||||
|
||||
func TestResolveTargetHomeWithOverride(t *testing.T) {
|
||||
result, err := ResolveTargetHome("/custom/path")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/custom/path", result)
|
||||
}
|
||||
|
||||
func TestCopyFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
sourceFile := filepath.Join(tmpDir, "source.txt")
|
||||
err := os.WriteFile(sourceFile, []byte("test content"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
dstFile := filepath.Join(tmpDir, "dest.txt")
|
||||
err = CopyFile(sourceFile, dstFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := os.ReadFile(dstFile)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test content", string(content))
|
||||
}
|
||||
|
||||
func TestCopyFileSourceNotFound(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
err := CopyFile(filepath.Join(tmpDir, "nonexistent.txt"), filepath.Join(tmpDir, "dest.txt"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPlanWorkspaceMigration(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
|
||||
dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
|
||||
|
||||
err := os.MkdirAll(srcWorkspace, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("content"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.MkdirAll(filepath.Join(srcWorkspace, "subdir"), 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filepath.Join(srcWorkspace, "subdir", "file2.txt"), []byte("content"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(
|
||||
srcWorkspace,
|
||||
dstWorkspace,
|
||||
[]string{"file1.txt"},
|
||||
[]string{"subdir"},
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.GreaterOrEqual(t, len(actions), 1)
|
||||
}
|
||||
|
||||
func TestPlanWorkspaceMigrationWithExistingDestination(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
|
||||
dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
|
||||
|
||||
err := os.MkdirAll(srcWorkspace, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.MkdirAll(dstWorkspace, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(
|
||||
srcWorkspace,
|
||||
dstWorkspace,
|
||||
[]string{"file1.txt"},
|
||||
[]string{},
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.GreaterOrEqual(t, len(actions), 1)
|
||||
assert.Equal(t, ActionBackup, actions[0].Type)
|
||||
}
|
||||
|
||||
func TestPlanWorkspaceMigrationForce(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
|
||||
dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
|
||||
|
||||
err := os.MkdirAll(srcWorkspace, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.MkdirAll(dstWorkspace, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(
|
||||
srcWorkspace,
|
||||
dstWorkspace,
|
||||
[]string{"file1.txt"},
|
||||
[]string{},
|
||||
true,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.GreaterOrEqual(t, len(actions), 1)
|
||||
assert.Equal(t, ActionCopy, actions[0].Type)
|
||||
}
|
||||
|
||||
func TestPlanWorkspaceMigrationNonExistentSource(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
actions, err := PlanWorkspaceMigration(
|
||||
filepath.Join(tmpDir, "nonexistent"),
|
||||
filepath.Join(tmpDir, "dst", "workspace"),
|
||||
[]string{"file1.txt"},
|
||||
[]string{},
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, actions, 1)
|
||||
assert.Equal(t, ActionSkip, actions[0].Type)
|
||||
assert.Contains(t, actions[0].Description, "source file not found")
|
||||
}
|
||||
52
pkg/migrate/internal/types.go
Normal file
52
pkg/migrate/internal/types.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package internal
|
||||
|
||||
type Options struct {
|
||||
DryRun bool
|
||||
ConfigOnly bool
|
||||
WorkspaceOnly bool
|
||||
Force bool
|
||||
Refresh bool
|
||||
Source string
|
||||
SourceHome string
|
||||
TargetHome string
|
||||
}
|
||||
|
||||
type Operation interface {
|
||||
GetSourceName() string
|
||||
GetSourceHome() (string, error)
|
||||
GetSourceWorkspace() (string, error)
|
||||
GetSourceConfigFile() (string, error)
|
||||
ExecuteConfigMigration(srcConfigPath, dstConfigPath string) error
|
||||
GetMigrateableFiles() []string
|
||||
GetMigrateableDirs() []string
|
||||
}
|
||||
|
||||
type HandlerFactory func(opts Options) Operation
|
||||
|
||||
type ActionType int
|
||||
|
||||
const (
|
||||
ActionCopy ActionType = iota
|
||||
ActionSkip
|
||||
ActionBackup
|
||||
ActionConvertConfig
|
||||
ActionCreateDir
|
||||
ActionMergeConfig
|
||||
)
|
||||
|
||||
type Action struct {
|
||||
Type ActionType
|
||||
Source string
|
||||
Target string
|
||||
Description string
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
FilesCopied int
|
||||
FilesSkipped int
|
||||
BackupsCreated int
|
||||
ConfigMigrated bool
|
||||
DirsCreated int
|
||||
Warnings []string
|
||||
Errors []error
|
||||
}
|
||||
|
|
@ -2,53 +2,73 @@ package migrate
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/migrate/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/migrate/sources/openclaw"
|
||||
)
|
||||
|
||||
type ActionType int
|
||||
type (
|
||||
Options = internal.Options
|
||||
Operation = internal.Operation
|
||||
ActionType = internal.ActionType
|
||||
Action = internal.Action
|
||||
Result = internal.Result
|
||||
HandlerFactory = internal.HandlerFactory
|
||||
)
|
||||
|
||||
const (
|
||||
ActionCopy ActionType = iota
|
||||
ActionSkip
|
||||
ActionBackup
|
||||
ActionConvertConfig
|
||||
ActionCreateDir
|
||||
ActionMergeConfig
|
||||
ActionCopy = internal.ActionCopy
|
||||
ActionSkip = internal.ActionSkip
|
||||
ActionBackup = internal.ActionBackup
|
||||
ActionConvertConfig = internal.ActionConvertConfig
|
||||
ActionCreateDir = internal.ActionCreateDir
|
||||
ActionMergeConfig = internal.ActionMergeConfig
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
DryRun bool
|
||||
ConfigOnly bool
|
||||
WorkspaceOnly bool
|
||||
Force bool
|
||||
Refresh bool
|
||||
OpenClawHome string
|
||||
PicoClawHome string
|
||||
type MigrateInstance struct {
|
||||
options Options
|
||||
handlers map[string]Operation
|
||||
}
|
||||
|
||||
type Action struct {
|
||||
Type ActionType
|
||||
Source string
|
||||
Destination string
|
||||
Description string
|
||||
func NewMigrateInstance(opts Options) *MigrateInstance {
|
||||
instance := &MigrateInstance{
|
||||
options: opts,
|
||||
handlers: make(map[string]Operation),
|
||||
}
|
||||
|
||||
openclaw_handler, err := openclaw.NewOpenclawHandler(opts)
|
||||
if err == nil {
|
||||
instance.Register(openclaw_handler.GetSourceName(), openclaw_handler)
|
||||
}
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
FilesCopied int
|
||||
FilesSkipped int
|
||||
BackupsCreated int
|
||||
ConfigMigrated bool
|
||||
DirsCreated int
|
||||
Warnings []string
|
||||
Errors []error
|
||||
func (m *MigrateInstance) Register(moduleName string, module Operation) {
|
||||
m.handlers[moduleName] = module
|
||||
}
|
||||
|
||||
func Run(opts Options) (*Result, error) {
|
||||
func (m *MigrateInstance) getCurrentHandler() (Operation, error) {
|
||||
source := m.options.Source
|
||||
if source == "" {
|
||||
source = "openclaw"
|
||||
}
|
||||
handler, ok := m.handlers[source]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Source '%s' not found", source)
|
||||
}
|
||||
return handler, nil
|
||||
}
|
||||
|
||||
func (m *MigrateInstance) Run(opts Options) (*Result, error) {
|
||||
handler, err := m.getCurrentHandler()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if opts.ConfigOnly && opts.WorkspaceOnly {
|
||||
return nil, fmt.Errorf("--config-only and --workspace-only are mutually exclusive")
|
||||
}
|
||||
|
|
@ -57,28 +77,28 @@ func Run(opts Options) (*Result, error) {
|
|||
opts.WorkspaceOnly = true
|
||||
}
|
||||
|
||||
openclawHome, err := resolveOpenClawHome(opts.OpenClawHome)
|
||||
sourceHome, err := handler.GetSourceHome()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
picoClawHome, err := resolvePicoClawHome(opts.PicoClawHome)
|
||||
targetHome, err := internal.ResolveTargetHome(opts.TargetHome)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err = os.Stat(openclawHome); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome)
|
||||
if _, err = os.Stat(sourceHome); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("Source installation not found at %s", sourceHome)
|
||||
}
|
||||
|
||||
actions, warnings, err := Plan(opts, openclawHome, picoClawHome)
|
||||
actions, warnings, err := m.Plan(opts, sourceHome, targetHome)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Println("Migrating from OpenClaw to PicoClaw")
|
||||
fmt.Printf(" Source: %s\n", openclawHome)
|
||||
fmt.Printf(" Destination: %s\n", picoClawHome)
|
||||
fmt.Println("Migrating from Source to PicoClaw")
|
||||
fmt.Printf(" Source: %s\n", sourceHome)
|
||||
fmt.Printf(" Target: %s\n", targetHome)
|
||||
fmt.Println()
|
||||
|
||||
if opts.DryRun {
|
||||
|
|
@ -95,19 +115,23 @@ func Run(opts Options) (*Result, error) {
|
|||
fmt.Println()
|
||||
}
|
||||
|
||||
result := Execute(actions, openclawHome, picoClawHome)
|
||||
result := m.Execute(actions, sourceHome, targetHome)
|
||||
result.Warnings = warnings
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func Plan(opts Options, openclawHome, picoClawHome string) ([]Action, []string, error) {
|
||||
func (m *MigrateInstance) Plan(opts Options, sourceHome, targetHome string) ([]Action, []string, error) {
|
||||
var actions []Action
|
||||
var warnings []string
|
||||
handler, err := m.getCurrentHandler()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
force := opts.Force || opts.Refresh
|
||||
|
||||
if !opts.WorkspaceOnly {
|
||||
configPath, err := findOpenClawConfig(openclawHome)
|
||||
configPath, err := handler.GetSourceConfigFile()
|
||||
if err != nil {
|
||||
if opts.ConfigOnly {
|
||||
return nil, nil, err
|
||||
|
|
@ -117,91 +141,95 @@ func Plan(opts Options, openclawHome, picoClawHome string) ([]Action, []string,
|
|||
actions = append(actions, Action{
|
||||
Type: ActionConvertConfig,
|
||||
Source: configPath,
|
||||
Destination: filepath.Join(picoClawHome, "config.json"),
|
||||
Description: "convert OpenClaw config to PicoClaw format",
|
||||
Target: filepath.Join(targetHome, "config.json"),
|
||||
Description: "convert Source config to PicoClaw format",
|
||||
})
|
||||
|
||||
data, err := LoadOpenClawConfig(configPath)
|
||||
if err == nil {
|
||||
_, configWarnings, _ := ConvertConfig(data)
|
||||
warnings = append(warnings, configWarnings...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.ConfigOnly {
|
||||
srcWorkspace := resolveWorkspace(openclawHome)
|
||||
dstWorkspace := resolveWorkspace(picoClawHome)
|
||||
srcWorkspace, err := handler.GetSourceWorkspace()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("getting source workspace: %w", err)
|
||||
}
|
||||
dstWorkspace := internal.ResolveWorkspace(targetHome)
|
||||
|
||||
if _, err := os.Stat(srcWorkspace); err == nil {
|
||||
wsActions, err := PlanWorkspaceMigration(srcWorkspace, dstWorkspace, force)
|
||||
wsActions, err := internal.PlanWorkspaceMigration(srcWorkspace, dstWorkspace,
|
||||
handler.GetMigrateableFiles(),
|
||||
handler.GetMigrateableDirs(),
|
||||
force)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("planning workspace migration: %w", err)
|
||||
}
|
||||
actions = append(actions, wsActions...)
|
||||
} else {
|
||||
warnings = append(warnings, "OpenClaw workspace directory not found, skipping workspace migration")
|
||||
warnings = append(warnings, "Source workspace directory not found, skipping workspace migration")
|
||||
}
|
||||
}
|
||||
|
||||
return actions, warnings, nil
|
||||
}
|
||||
|
||||
func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
|
||||
func (m *MigrateInstance) Execute(actions []Action, sourceHome, targetHome string) *Result {
|
||||
result := &Result{}
|
||||
handler, err := m.getCurrentHandler()
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
for _, action := range actions {
|
||||
switch action.Type {
|
||||
case ActionConvertConfig:
|
||||
if err := executeConfigMigration(action.Source, action.Destination, picoClawHome); err != nil {
|
||||
if err := handler.ExecuteConfigMigration(action.Source, action.Target); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Errorf("config migration: %w", err))
|
||||
fmt.Printf(" ✗ Config migration failed: %v\n", err)
|
||||
} else {
|
||||
result.ConfigMigrated = true
|
||||
fmt.Printf(" ✓ Converted config: %s\n", action.Destination)
|
||||
fmt.Printf(" ✓ Converted config: %s\n", action.Target)
|
||||
}
|
||||
case ActionCreateDir:
|
||||
if err := os.MkdirAll(action.Destination, 0o755); err != nil {
|
||||
if err := os.MkdirAll(action.Target, 0o755); err != nil {
|
||||
result.Errors = append(result.Errors, err)
|
||||
} else {
|
||||
result.DirsCreated++
|
||||
}
|
||||
case ActionBackup:
|
||||
bakPath := action.Destination + ".bak"
|
||||
if err := copyFile(action.Destination, bakPath); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Errorf("backup %s: %w", action.Destination, err))
|
||||
fmt.Printf(" ✗ Backup failed: %s\n", action.Destination)
|
||||
bakPath := action.Target + ".bak"
|
||||
if err := internal.CopyFile(action.Target, bakPath); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Errorf("backup %s: %w", action.Target, err))
|
||||
fmt.Printf(" ✗ Backup failed: %s\n", action.Target)
|
||||
continue
|
||||
}
|
||||
result.BackupsCreated++
|
||||
fmt.Printf(
|
||||
" ✓ Backed up %s -> %s.bak\n",
|
||||
filepath.Base(action.Destination),
|
||||
filepath.Base(action.Destination),
|
||||
filepath.Base(action.Target),
|
||||
filepath.Base(action.Target),
|
||||
)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(action.Target), 0o755); err != nil {
|
||||
result.Errors = append(result.Errors, err)
|
||||
continue
|
||||
}
|
||||
if err := copyFile(action.Source, action.Destination); err != nil {
|
||||
if err := internal.CopyFile(action.Source, action.Target); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err))
|
||||
fmt.Printf(" ✗ Copy failed: %s\n", action.Source)
|
||||
} else {
|
||||
result.FilesCopied++
|
||||
fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
|
||||
fmt.Printf(" ✓ Copied %s\n", internal.RelPath(action.Source, sourceHome))
|
||||
}
|
||||
case ActionCopy:
|
||||
if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(action.Target), 0o755); err != nil {
|
||||
result.Errors = append(result.Errors, err)
|
||||
continue
|
||||
}
|
||||
if err := copyFile(action.Source, action.Destination); err != nil {
|
||||
if err := internal.CopyFile(action.Source, action.Target); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err))
|
||||
fmt.Printf(" ✗ Copy failed: %s\n", action.Source)
|
||||
} else {
|
||||
result.FilesCopied++
|
||||
fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
|
||||
fmt.Printf(" ✓ Copied %s\n", internal.RelPath(action.Source, sourceHome))
|
||||
}
|
||||
case ActionSkip:
|
||||
result.FilesSkipped++
|
||||
|
|
@ -211,31 +239,6 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
|
|||
return result
|
||||
}
|
||||
|
||||
func executeConfigMigration(srcConfigPath, dstConfigPath, picoClawHome string) error {
|
||||
data, err := LoadOpenClawConfig(srcConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
incoming, _, err := ConvertConfig(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dstConfigPath); err == nil {
|
||||
existing, err := config.LoadConfig(dstConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading existing PicoClaw config: %w", err)
|
||||
}
|
||||
incoming = MergeConfig(existing, incoming)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return config.SaveConfig(dstConfigPath, incoming)
|
||||
}
|
||||
|
||||
func Confirm() bool {
|
||||
fmt.Print("Proceed with migration? (y/n): ")
|
||||
var response string
|
||||
|
|
@ -243,49 +246,7 @@ func Confirm() bool {
|
|||
return strings.ToLower(strings.TrimSpace(response)) == "y"
|
||||
}
|
||||
|
||||
func PrintPlan(actions []Action, warnings []string) {
|
||||
fmt.Println("Planned actions:")
|
||||
copies := 0
|
||||
skips := 0
|
||||
backups := 0
|
||||
configCount := 0
|
||||
|
||||
for _, action := range actions {
|
||||
switch action.Type {
|
||||
case ActionConvertConfig:
|
||||
fmt.Printf(" [config] %s -> %s\n", action.Source, action.Destination)
|
||||
configCount++
|
||||
case ActionCopy:
|
||||
fmt.Printf(" [copy] %s\n", filepath.Base(action.Source))
|
||||
copies++
|
||||
case ActionBackup:
|
||||
fmt.Printf(" [backup] %s (exists, will backup and overwrite)\n", filepath.Base(action.Destination))
|
||||
backups++
|
||||
copies++
|
||||
case ActionSkip:
|
||||
if action.Description != "" {
|
||||
fmt.Printf(" [skip] %s (%s)\n", filepath.Base(action.Source), action.Description)
|
||||
}
|
||||
skips++
|
||||
case ActionCreateDir:
|
||||
fmt.Printf(" [mkdir] %s\n", action.Destination)
|
||||
}
|
||||
}
|
||||
|
||||
if len(warnings) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println("Warnings:")
|
||||
for _, w := range warnings {
|
||||
fmt.Printf(" - %s\n", w)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%d files to copy, %d configs to convert, %d backups needed, %d skipped\n",
|
||||
copies, configCount, backups, skips)
|
||||
}
|
||||
|
||||
func PrintSummary(result *Result) {
|
||||
func (m *MigrateInstance) PrintSummary(result *Result) {
|
||||
fmt.Println()
|
||||
parts := []string{}
|
||||
if result.FilesCopied > 0 {
|
||||
|
|
@ -316,83 +277,44 @@ func PrintSummary(result *Result) {
|
|||
}
|
||||
}
|
||||
|
||||
func resolveOpenClawHome(override string) (string, error) {
|
||||
if override != "" {
|
||||
return expandHome(override), nil
|
||||
func PrintPlan(actions []Action, warnings []string) {
|
||||
fmt.Println("Planned actions:")
|
||||
copies := 0
|
||||
skips := 0
|
||||
backups := 0
|
||||
configCount := 0
|
||||
|
||||
for _, action := range actions {
|
||||
switch action.Type {
|
||||
case ActionConvertConfig:
|
||||
fmt.Printf(" [config] %s -> %s\n", action.Source, action.Target)
|
||||
configCount++
|
||||
case ActionCopy:
|
||||
fmt.Printf(" [copy] %s\n", filepath.Base(action.Source))
|
||||
copies++
|
||||
case ActionBackup:
|
||||
fmt.Printf(" [backup] %s (exists, will backup and overwrite)\n", filepath.Base(action.Target))
|
||||
backups++
|
||||
copies++
|
||||
case ActionSkip:
|
||||
if action.Description != "" {
|
||||
fmt.Printf(" [skip] %s (%s)\n", filepath.Base(action.Source), action.Description)
|
||||
}
|
||||
if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" {
|
||||
return expandHome(envHome), nil
|
||||
skips++
|
||||
case ActionCreateDir:
|
||||
fmt.Printf(" [mkdir] %s\n", action.Target)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".openclaw"), nil
|
||||
}
|
||||
|
||||
func resolvePicoClawHome(override string) (string, error) {
|
||||
if override != "" {
|
||||
return expandHome(override), nil
|
||||
}
|
||||
if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" {
|
||||
return expandHome(envHome), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw"), nil
|
||||
}
|
||||
|
||||
func resolveWorkspace(homeDir string) string {
|
||||
return filepath.Join(homeDir, "workspace")
|
||||
}
|
||||
|
||||
func expandHome(path string) string {
|
||||
if path == "" {
|
||||
return path
|
||||
}
|
||||
if path[0] == '~' {
|
||||
home, _ := os.UserHomeDir()
|
||||
if len(path) > 1 && path[1] == '/' {
|
||||
return home + path[1:]
|
||||
}
|
||||
return home
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func backupFile(path string) error {
|
||||
bakPath := path + ".bak"
|
||||
return copyFile(path, bakPath)
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
info, err := srcFile.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
}
|
||||
|
||||
func relPath(path, base string) string {
|
||||
rel, err := filepath.Rel(base, path)
|
||||
if err != nil {
|
||||
return filepath.Base(path)
|
||||
}
|
||||
return rel
|
||||
|
||||
if len(warnings) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println("Warnings:")
|
||||
for _, w := range warnings {
|
||||
fmt.Printf(" - %s\n", w)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%d files to copy, %d configs to convert, %d backups needed, %d skipped\n",
|
||||
copies, configCount, backups, skips)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
29
pkg/migrate/sources/openclaw/common.go
Normal file
29
pkg/migrate/sources/openclaw/common.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package openclaw
|
||||
|
||||
var migrateableFiles = []string{
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"USER.md",
|
||||
"TOOLS.md",
|
||||
"HEARTBEAT.md",
|
||||
}
|
||||
|
||||
var migrateableDirs = []string{
|
||||
"memory",
|
||||
"skills",
|
||||
}
|
||||
|
||||
var supportedChannels = map[string]bool{
|
||||
"whatsapp": true,
|
||||
"telegram": true,
|
||||
"feishu": true,
|
||||
"discord": true,
|
||||
"maixcam": true,
|
||||
"qq": true,
|
||||
"dingtalk": true,
|
||||
"slack": true,
|
||||
"line": true,
|
||||
"onebot": true,
|
||||
"wecom": true,
|
||||
"wecom_app": true,
|
||||
}
|
||||
1074
pkg/migrate/sources/openclaw/openclaw_config.go
Normal file
1074
pkg/migrate/sources/openclaw/openclaw_config.go
Normal file
File diff suppressed because it is too large
Load diff
714
pkg/migrate/sources/openclaw/openclaw_config_test.go
Normal file
714
pkg/migrate/sources/openclaw/openclaw_config_test.go
Normal file
|
|
@ -0,0 +1,714 @@
|
|||
package openclaw
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadOpenClawConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
|
||||
testConfig := `{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": {
|
||||
"primary": "anthropic/claude-sonnet-4-20250514"
|
||||
},
|
||||
"workspace": "~/.openclaw/workspace"
|
||||
},
|
||||
"list": [
|
||||
{
|
||||
"id": "main",
|
||||
"name": "Main Agent",
|
||||
"model": {
|
||||
"primary": "openai/gpt-4o",
|
||||
"fallbacks": ["claude-3-opus"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"botToken": "test-token",
|
||||
"allowFrom": ["user1", "user2"]
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "discord-token"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"api_key": "sk-ant-test",
|
||||
"base_url": "https://api.anthropic.com"
|
||||
},
|
||||
"openai": {
|
||||
"api_key": "sk-test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
err := os.WriteFile(configPath, []byte(testConfig), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadOpenClawConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Agents == nil {
|
||||
t.Error("agents should not be nil")
|
||||
}
|
||||
|
||||
if cfg.Agents.Defaults == nil {
|
||||
t.Error("agents.defaults should not be nil")
|
||||
}
|
||||
|
||||
provider, model := cfg.GetDefaultModel()
|
||||
if provider != "anthropic" {
|
||||
t.Errorf("expected provider 'anthropic', got '%s'", provider)
|
||||
}
|
||||
if model != "claude-sonnet-4-20250514" {
|
||||
t.Errorf("expected model 'claude-sonnet-4-20250514', got '%s'", model)
|
||||
}
|
||||
|
||||
workspace := cfg.GetDefaultWorkspace()
|
||||
if workspace != "~/.picoclaw/workspace" {
|
||||
t.Errorf("expected workspace '~/.picoclaw/workspace', got '%s'", workspace)
|
||||
}
|
||||
|
||||
agents := cfg.GetAgents()
|
||||
if len(agents) != 1 {
|
||||
t.Errorf("expected 1 agent, got %d", len(agents))
|
||||
}
|
||||
if agents[0].ID != "main" {
|
||||
t.Errorf("expected agent id 'main', got '%s'", agents[0].ID)
|
||||
}
|
||||
|
||||
if cfg.Channels == nil {
|
||||
t.Error("channels should not be nil")
|
||||
}
|
||||
if cfg.Channels.Telegram == nil {
|
||||
t.Error("telegram channel should not be nil")
|
||||
}
|
||||
if cfg.Channels.Telegram.BotToken == nil || *cfg.Channels.Telegram.BotToken != "test-token" {
|
||||
t.Error("telegram bot token not parsed correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProviderConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
|
||||
testConfig := `{
|
||||
"models": {
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"api_key": "sk-ant-test",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
"max_tokens": 4096
|
||||
},
|
||||
"openai": {
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://api.openai.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
err := os.WriteFile(configPath, []byte(testConfig), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadOpenClawConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
providers := GetProviderConfig(cfg.Models)
|
||||
if len(providers) != 2 {
|
||||
t.Errorf("expected 2 providers, got %d", len(providers))
|
||||
}
|
||||
|
||||
if anthropic, ok := providers["anthropic"]; ok {
|
||||
if anthropic.APIKey != "sk-ant-test" {
|
||||
t.Errorf("expected anthropic api_key 'sk-ant-test', got '%s'", anthropic.APIKey)
|
||||
}
|
||||
if anthropic.BaseURL != "https://api.anthropic.com" {
|
||||
t.Errorf("expected anthropic base_url 'https://api.anthropic.com', got '%s'", anthropic.BaseURL)
|
||||
}
|
||||
} else {
|
||||
t.Error("anthropic provider not found")
|
||||
}
|
||||
|
||||
if openai, ok := providers["openai"]; ok {
|
||||
if openai.APIKey != "sk-test" {
|
||||
t.Errorf("expected openai api_key 'sk-test', got '%s'", openai.APIKey)
|
||||
}
|
||||
} else {
|
||||
t.Error("openai provider not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToPicoClaw(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
|
||||
testConfig := `{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": {
|
||||
"primary": "anthropic/claude-sonnet-4-20250514"
|
||||
},
|
||||
"workspace": "~/.openclaw/workspace"
|
||||
},
|
||||
"list": [
|
||||
{
|
||||
"id": "main",
|
||||
"name": "Main Agent"
|
||||
},
|
||||
{
|
||||
"id": "assistant",
|
||||
"name": "Assistant",
|
||||
"skills": ["skill1", "skill2"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"botToken": "test-token",
|
||||
"allowFrom": ["user1", "user2"]
|
||||
},
|
||||
"discord": {
|
||||
"enabled": false,
|
||||
"token": "discord-token"
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": true,
|
||||
"bridgeUrl": "http://localhost:3000"
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": true,
|
||||
"appId": "app-id",
|
||||
"appSecret": "app-secret",
|
||||
"allowFrom": ["user3"]
|
||||
},
|
||||
"signal": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"api_key": "sk-ant-test"
|
||||
},
|
||||
"openai": {
|
||||
"api_key": "sk-test"
|
||||
}
|
||||
}
|
||||
},
|
||||
"skills": {
|
||||
"entries": {
|
||||
"skill1": {}
|
||||
}
|
||||
},
|
||||
"memory": {"enabled": true},
|
||||
"cron": {"enabled": true}
|
||||
}`
|
||||
|
||||
err := os.WriteFile(configPath, []byte(testConfig), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadOpenClawConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
picoCfg, warnings, err := cfg.ConvertToPicoClaw("")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to convert config: %v", err)
|
||||
}
|
||||
|
||||
if picoCfg.Agents.Defaults.ModelName != "claude-sonnet-4-20250514" {
|
||||
t.Errorf("expected model 'claude-sonnet-4-20250514', got '%s'", picoCfg.Agents.Defaults.ModelName)
|
||||
}
|
||||
if picoCfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" {
|
||||
t.Errorf("expected workspace '~/.picoclaw/workspace', got '%s'", picoCfg.Agents.Defaults.Workspace)
|
||||
}
|
||||
|
||||
if len(picoCfg.Agents.List) != 2 {
|
||||
t.Errorf("expected 2 agents, got %d", len(picoCfg.Agents.List))
|
||||
}
|
||||
if picoCfg.Agents.List[0].ID != "main" {
|
||||
t.Errorf("expected first agent id 'main', got '%s'", picoCfg.Agents.List[0].ID)
|
||||
}
|
||||
if picoCfg.Agents.List[1].Skills == nil || len(picoCfg.Agents.List[1].Skills) != 2 {
|
||||
t.Errorf("expected 2 skills for assistant agent")
|
||||
}
|
||||
|
||||
if !picoCfg.Channels.Telegram.Enabled {
|
||||
t.Error("telegram should be enabled")
|
||||
}
|
||||
if picoCfg.Channels.Telegram.Token != "test-token" {
|
||||
t.Errorf("expected telegram token 'test-token', got '%s'", picoCfg.Channels.Telegram.Token)
|
||||
}
|
||||
|
||||
if picoCfg.Channels.WhatsApp.BridgeURL != "http://localhost:3000" {
|
||||
t.Errorf("expected whatsapp bridge URL 'http://localhost:3000', got '%s'", picoCfg.Channels.WhatsApp.BridgeURL)
|
||||
}
|
||||
|
||||
if picoCfg.Channels.Feishu.AppID != "app-id" {
|
||||
t.Errorf("expected feishu app ID 'app-id', got '%s'", picoCfg.Channels.Feishu.AppID)
|
||||
}
|
||||
|
||||
if len(picoCfg.ModelList) != 1 {
|
||||
t.Errorf("expected 1 model config (no models.json provided), got %d", len(picoCfg.ModelList))
|
||||
}
|
||||
|
||||
foundWarning := false
|
||||
for _, w := range warnings {
|
||||
if len(w) > 0 {
|
||||
foundWarning = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundWarning {
|
||||
t.Log("warnings should be generated for skills, memory, cron, and unsupported channels")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
|
||||
testConfig := `{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": {
|
||||
"primary": "anthropic/claude-sonnet-4-20250514"
|
||||
}
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"qq": {
|
||||
"enabled": true,
|
||||
"appId": "qq-app-id",
|
||||
"appSecret": "qq-app-secret"
|
||||
},
|
||||
"dingtalk": {
|
||||
"enabled": true,
|
||||
"appId": "ding-app-id",
|
||||
"appSecret": "ding-app-secret"
|
||||
},
|
||||
"maixcam": {
|
||||
"enabled": true,
|
||||
"host": "192.168.1.100",
|
||||
"port": 9000
|
||||
},
|
||||
"slack": {
|
||||
"enabled": true,
|
||||
"botToken": "xoxb-test",
|
||||
"appToken": "xapp-test"
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
err := os.WriteFile(configPath, []byte(testConfig), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadOpenClawConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
picoCfg, _, err := cfg.ConvertToPicoClaw("")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to convert config: %v", err)
|
||||
}
|
||||
|
||||
if !picoCfg.Channels.QQ.Enabled {
|
||||
t.Error("qq should be enabled")
|
||||
}
|
||||
if picoCfg.Channels.QQ.AppID != "qq-app-id" {
|
||||
t.Errorf("expected qq app ID 'qq-app-id', got '%s'", picoCfg.Channels.QQ.AppID)
|
||||
}
|
||||
|
||||
if !picoCfg.Channels.DingTalk.Enabled {
|
||||
t.Error("dingtalk should be enabled")
|
||||
}
|
||||
if picoCfg.Channels.DingTalk.ClientID != "ding-app-id" {
|
||||
t.Errorf("expected dingtalk client ID 'ding-app-id', got '%s'", picoCfg.Channels.DingTalk.ClientID)
|
||||
}
|
||||
|
||||
if !picoCfg.Channels.MaixCam.Enabled {
|
||||
t.Error("maixcam should be enabled")
|
||||
}
|
||||
if picoCfg.Channels.MaixCam.Host != "192.168.1.100" {
|
||||
t.Errorf("expected maixcam host '192.168.1.100', got '%s'", picoCfg.Channels.MaixCam.Host)
|
||||
}
|
||||
if picoCfg.Channels.MaixCam.Port != 9000 {
|
||||
t.Errorf("expected maixcam port 9000, got %d", picoCfg.Channels.MaixCam.Port)
|
||||
}
|
||||
|
||||
if !picoCfg.Channels.Slack.Enabled {
|
||||
t.Error("slack should be enabled")
|
||||
}
|
||||
if picoCfg.Channels.Slack.BotToken != "xoxb-test" {
|
||||
t.Errorf("expected slack bot token 'xoxb-test', got '%s'", picoCfg.Channels.Slack.BotToken)
|
||||
}
|
||||
if picoCfg.Channels.Slack.AppToken != "xapp-test" {
|
||||
t.Errorf("expected slack app token 'xapp-test', got '%s'", picoCfg.Channels.Slack.AppToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenClawAgentModel(t *testing.T) {
|
||||
model := &OpenClawAgentModel{
|
||||
Primary: strPtr("anthropic/claude-3-opus"),
|
||||
Fallbacks: []string{"claude-3-sonnet", "claude-3-haiku"},
|
||||
}
|
||||
|
||||
primary := model.GetPrimary()
|
||||
if primary != "anthropic/claude-3-opus" {
|
||||
t.Errorf("expected primary 'anthropic/claude-3-opus', got '%s'", primary)
|
||||
}
|
||||
|
||||
fallbacks := model.GetFallbacks()
|
||||
if len(fallbacks) != 2 {
|
||||
t.Errorf("expected 2 fallbacks, got %d", len(fallbacks))
|
||||
}
|
||||
|
||||
model2 := &OpenClawAgentModel{
|
||||
Simple: "claude-3-opus",
|
||||
}
|
||||
|
||||
primary2 := model2.GetPrimary()
|
||||
if primary2 != "claude-3-opus" {
|
||||
t.Errorf("expected primary 'claude-3-opus' from Simple, got '%s'", primary2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelEnabled(t *testing.T) {
|
||||
cfg := &OpenClawConfig{
|
||||
Channels: &OpenClawChannels{
|
||||
Telegram: &OpenClawTelegramConfig{
|
||||
Enabled: boolPtr(true),
|
||||
},
|
||||
Discord: &OpenClawDiscordConfig{
|
||||
Enabled: boolPtr(false),
|
||||
},
|
||||
Slack: &OpenClawSlackConfig{
|
||||
Enabled: boolPtr(true),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if !cfg.IsChannelEnabled("telegram") {
|
||||
t.Error("telegram should be enabled")
|
||||
}
|
||||
if cfg.IsChannelEnabled("discord") {
|
||||
t.Error("discord should be disabled")
|
||||
}
|
||||
if !cfg.IsChannelEnabled("slack") {
|
||||
t.Error("slack should be enabled (explicitly set)")
|
||||
}
|
||||
if cfg.IsChannelEnabled("line") {
|
||||
t.Error("line should return false (not in switch cases)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDefaultModel(t *testing.T) {
|
||||
cfg := &OpenClawConfig{
|
||||
Agents: &OpenClawAgents{
|
||||
Defaults: &OpenClawAgentDefaults{
|
||||
Model: &OpenClawAgentModel{
|
||||
Primary: strPtr("openai/gpt-4"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
provider, model := cfg.GetDefaultModel()
|
||||
if provider != "openai" {
|
||||
t.Errorf("expected provider 'openai', got '%s'", provider)
|
||||
}
|
||||
if model != "gpt-4" {
|
||||
t.Errorf("expected model 'gpt-4', got '%s'", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDefaultModelWithNoDefaults(t *testing.T) {
|
||||
cfg := &OpenClawConfig{}
|
||||
|
||||
provider, model := cfg.GetDefaultModel()
|
||||
if provider != "anthropic" {
|
||||
t.Errorf("expected default provider 'anthropic', got '%s'", provider)
|
||||
}
|
||||
if model != "claude-sonnet-4-20250514" {
|
||||
t.Errorf("expected default model 'claude-sonnet-4-20250514', got '%s'", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFunctions(t *testing.T) {
|
||||
cfg := &OpenClawConfig{
|
||||
Skills: &OpenClawSkills{Entries: map[string]json.RawMessage{"skill1": nil}},
|
||||
Memory: json.RawMessage(`{"enabled": true}`),
|
||||
Cron: json.RawMessage(`{"enabled": true}`),
|
||||
Hooks: json.RawMessage(`{"enabled": true}`),
|
||||
Session: json.RawMessage(`{"enabled": true}`),
|
||||
Auth: &OpenClawAuth{Profiles: json.RawMessage(`{"profile1": {}}`)},
|
||||
}
|
||||
|
||||
if !cfg.HasSkills() {
|
||||
t.Error("should have skills")
|
||||
}
|
||||
if !cfg.HasMemory() {
|
||||
t.Error("should have memory")
|
||||
}
|
||||
if !cfg.HasCron() {
|
||||
t.Error("should have cron")
|
||||
}
|
||||
if !cfg.HasHooks() {
|
||||
t.Error("should have hooks")
|
||||
}
|
||||
if !cfg.HasSession() {
|
||||
t.Error("should have session")
|
||||
}
|
||||
if !cfg.HasAuthProfiles() {
|
||||
t.Error("should have auth profiles")
|
||||
}
|
||||
|
||||
cfg2 := &OpenClawConfig{}
|
||||
if cfg2.HasSkills() {
|
||||
t.Error("should not have skills")
|
||||
}
|
||||
if cfg2.HasMemory() {
|
||||
t.Error("should not have memory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOpenClawConfigFromDir(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
|
||||
testConfig := `{"agents": {}}`
|
||||
err := os.WriteFile(configPath, []byte(testConfig), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadOpenClawConfigFromDir(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config from dir: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Agents == nil {
|
||||
t.Error("agents should not be nil")
|
||||
}
|
||||
|
||||
_, err = LoadOpenClawConfigFromDir("/nonexistent/dir")
|
||||
if err == nil {
|
||||
t.Error("should return error for nonexistent dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToStandardConfig(t *testing.T) {
|
||||
picoCfg := &PicoClawConfig{
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Provider: "anthropic",
|
||||
ModelName: "claude-sonnet-4-20250514",
|
||||
Workspace: "~/.picoclaw/workspace",
|
||||
},
|
||||
List: []AgentConfig{
|
||||
{
|
||||
ID: "main",
|
||||
Name: "Main Agent",
|
||||
Default: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
ModelList: []ModelConfig{
|
||||
{
|
||||
ModelName: "claude-sonnet-4-20250514",
|
||||
Model: "anthropic/claude-sonnet-4-20250514",
|
||||
APIKey: "sk-ant-test",
|
||||
},
|
||||
},
|
||||
Channels: ChannelsConfig{
|
||||
Telegram: TelegramConfig{
|
||||
Enabled: true,
|
||||
Token: "test-token",
|
||||
AllowFrom: []string{"user1"},
|
||||
},
|
||||
WhatsApp: WhatsAppConfig{
|
||||
Enabled: true,
|
||||
BridgeURL: "http://localhost:3000",
|
||||
},
|
||||
},
|
||||
Gateway: GatewayConfig{
|
||||
Host: "0.0.0.0",
|
||||
Port: 8080,
|
||||
},
|
||||
}
|
||||
|
||||
stdCfg := picoCfg.ToStandardConfig()
|
||||
|
||||
if stdCfg.Agents.Defaults.Provider != "anthropic" {
|
||||
t.Errorf("expected provider 'anthropic', got '%s'", stdCfg.Agents.Defaults.Provider)
|
||||
}
|
||||
if stdCfg.Agents.Defaults.ModelName != "claude-sonnet-4-20250514" {
|
||||
t.Errorf("expected model name 'claude-sonnet-4-20250514', got '%s'", stdCfg.Agents.Defaults.ModelName)
|
||||
}
|
||||
if stdCfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" {
|
||||
t.Errorf("expected workspace '~/.picoclaw/workspace', got '%s'", stdCfg.Agents.Defaults.Workspace)
|
||||
}
|
||||
|
||||
if len(stdCfg.Agents.List) != 1 {
|
||||
t.Errorf("expected 1 agent, got %d", len(stdCfg.Agents.List))
|
||||
}
|
||||
if stdCfg.Agents.List[0].ID != "main" {
|
||||
t.Errorf("expected agent id 'main', got '%s'", stdCfg.Agents.List[0].ID)
|
||||
}
|
||||
|
||||
foundModel := false
|
||||
var foundAPIKey string
|
||||
for _, m := range stdCfg.ModelList {
|
||||
if m.ModelName == "claude-sonnet-4-20250514" {
|
||||
foundModel = true
|
||||
foundAPIKey = m.APIKey
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundModel {
|
||||
t.Error("expected to find claude-sonnet-4-20250514 model config")
|
||||
}
|
||||
if foundAPIKey != "sk-ant-test" {
|
||||
t.Errorf("expected api key 'sk-ant-test', got '%s'", foundAPIKey)
|
||||
}
|
||||
|
||||
if !stdCfg.Channels.Telegram.Enabled {
|
||||
t.Error("telegram should be enabled")
|
||||
}
|
||||
if stdCfg.Channels.Telegram.Token != "test-token" {
|
||||
t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token)
|
||||
}
|
||||
|
||||
if stdCfg.Gateway.Port != 8080 {
|
||||
t.Errorf("expected gateway port 8080, got %d", stdCfg.Gateway.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProviderConfigFromAgentsDir(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
agentsDir := filepath.Join(tmpDir, "agents", "main", "agent")
|
||||
err := os.MkdirAll(agentsDir, 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create agents dir: %v", err)
|
||||
}
|
||||
|
||||
modelsJSON := `{
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"baseUrl": "https://api.anthropic.com",
|
||||
"api": "anthropic",
|
||||
"apiKey": "sk-ant-from-models",
|
||||
"models": [
|
||||
{
|
||||
"id": "claude-sonnet-4-20250514",
|
||||
"name": "Claude Sonnet 4"
|
||||
}
|
||||
]
|
||||
},
|
||||
"openai": {
|
||||
"baseUrl": "https://api.openai.com",
|
||||
"api": "openai",
|
||||
"apiKey": "sk-from-models",
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-4o",
|
||||
"name": "GPT-4o"
|
||||
}
|
||||
]
|
||||
},
|
||||
"zhipu": {
|
||||
"baseUrl": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"api": "openai",
|
||||
"apiKey": "zhipu-key",
|
||||
"models": []
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
err = os.WriteFile(filepath.Join(agentsDir, "models.json"), []byte(modelsJSON), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write models.json: %v", err)
|
||||
}
|
||||
|
||||
providers := GetProviderConfigFromDir(tmpDir)
|
||||
if len(providers) != 3 {
|
||||
t.Errorf("expected 3 providers, got %d", len(providers))
|
||||
}
|
||||
|
||||
if anthropic, ok := providers["anthropic"]; ok {
|
||||
if anthropic.ApiKey != "sk-ant-from-models" {
|
||||
t.Errorf("expected anthropic apiKey 'sk-ant-from-models', got '%s'", anthropic.ApiKey)
|
||||
}
|
||||
if anthropic.BaseUrl != "https://api.anthropic.com" {
|
||||
t.Errorf("expected anthropic baseUrl 'https://api.anthropic.com', got '%s'", anthropic.BaseUrl)
|
||||
}
|
||||
} else {
|
||||
t.Error("anthropic provider not found")
|
||||
}
|
||||
|
||||
if openai, ok := providers["openai"]; ok {
|
||||
if openai.ApiKey != "sk-from-models" {
|
||||
t.Errorf("expected openai apiKey 'sk-from-models', got '%s'", openai.ApiKey)
|
||||
}
|
||||
if openai.BaseUrl != "https://api.openai.com" {
|
||||
t.Errorf("expected openai baseUrl 'https://api.openai.com', got '%s'", openai.BaseUrl)
|
||||
}
|
||||
} else {
|
||||
t.Error("openai provider not found")
|
||||
}
|
||||
|
||||
if zhipu, ok := providers["zhipu"]; ok {
|
||||
if zhipu.ApiKey != "zhipu-key" {
|
||||
t.Errorf("expected zhipu apiKey 'zhipu-key', got '%s'", zhipu.ApiKey)
|
||||
}
|
||||
if zhipu.BaseUrl != "https://open.bigmodel.cn/api/paas/v4" {
|
||||
t.Errorf("expected zhipu baseUrl 'https://open.bigmodel.cn/api/paas/v4', got '%s'", zhipu.BaseUrl)
|
||||
}
|
||||
} else {
|
||||
t.Error("zhipu provider not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProviderConfigFromDirNotExist(t *testing.T) {
|
||||
providers := GetProviderConfigFromDir("/nonexistent/path")
|
||||
if len(providers) != 0 {
|
||||
t.Errorf("expected 0 providers for nonexistent path, got %d", len(providers))
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
148
pkg/migrate/sources/openclaw/openclaw_handler.go
Normal file
148
pkg/migrate/sources/openclaw/openclaw_handler.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package openclaw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/migrate/internal"
|
||||
)
|
||||
|
||||
var providerMapping = map[string]string{
|
||||
"anthropic": "anthropic",
|
||||
"claude": "anthropic",
|
||||
"openai": "openai",
|
||||
"gpt": "openai",
|
||||
"groq": "groq",
|
||||
"ollama": "ollama",
|
||||
"openrouter": "openrouter",
|
||||
"deepseek": "deepseek",
|
||||
"together": "together",
|
||||
"mistral": "mistral",
|
||||
"fireworks": "fireworks",
|
||||
"google": "google",
|
||||
"gemini": "google",
|
||||
"xai": "xai",
|
||||
"grok": "xai",
|
||||
"cerebras": "cerebras",
|
||||
"sambanova": "sambanova",
|
||||
}
|
||||
|
||||
type OpenclawHandler struct {
|
||||
opts Options
|
||||
sourceConfigFile string
|
||||
sourceWorkspace string
|
||||
}
|
||||
|
||||
type (
|
||||
Options = internal.Options
|
||||
Action = internal.Action
|
||||
Result = internal.Result
|
||||
Operation = internal.Operation
|
||||
)
|
||||
|
||||
func NewOpenclawHandler(opts Options) (Operation, error) {
|
||||
home, err := resolveSourceHome(opts.SourceHome)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.SourceHome = home
|
||||
|
||||
configFile, err := findSourceConfig(home)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &OpenclawHandler{
|
||||
opts: opts,
|
||||
sourceWorkspace: filepath.Join(opts.SourceHome, "workspace"),
|
||||
sourceConfigFile: configFile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (o *OpenclawHandler) GetSourceName() string {
|
||||
return "openclaw"
|
||||
}
|
||||
|
||||
func (o *OpenclawHandler) GetSourceHome() (string, error) {
|
||||
return o.opts.SourceHome, nil
|
||||
}
|
||||
|
||||
func (o *OpenclawHandler) GetSourceWorkspace() (string, error) {
|
||||
return o.sourceWorkspace, nil
|
||||
}
|
||||
|
||||
func (o *OpenclawHandler) GetSourceConfigFile() (string, error) {
|
||||
return o.sourceConfigFile, nil
|
||||
}
|
||||
|
||||
func (o *OpenclawHandler) GetMigrateableFiles() []string {
|
||||
return migrateableFiles
|
||||
}
|
||||
|
||||
func (o *OpenclawHandler) GetMigrateableDirs() []string {
|
||||
return migrateableDirs
|
||||
}
|
||||
|
||||
func (o *OpenclawHandler) ExecuteConfigMigration(srcConfigPath, dstConfigPath string) error {
|
||||
openclawCfg, err := LoadOpenClawConfig(srcConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
picoCfg, warnings, err := openclawCfg.ConvertToPicoClaw(o.opts.SourceHome)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, w := range warnings {
|
||||
fmt.Printf(" Warning: %s\n", w)
|
||||
}
|
||||
|
||||
incoming := picoCfg.ToStandardConfig()
|
||||
if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return config.SaveConfig(dstConfigPath, incoming)
|
||||
}
|
||||
|
||||
func resolveSourceHome(override string) (string, error) {
|
||||
if override != "" {
|
||||
return internal.ExpandHome(override), nil
|
||||
}
|
||||
if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" {
|
||||
return internal.ExpandHome(envHome), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".openclaw"), nil
|
||||
}
|
||||
|
||||
func findSourceConfig(sourceHome string) (string, error) {
|
||||
candidates := []string{
|
||||
filepath.Join(sourceHome, "openclaw.json"),
|
||||
filepath.Join(sourceHome, "config.json"),
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", sourceHome)
|
||||
}
|
||||
|
||||
func rewriteWorkspacePath(path string) string {
|
||||
path = strings.Replace(path, ".openclaw", ".picoclaw", 1)
|
||||
return path
|
||||
}
|
||||
|
||||
func mapProvider(provider string) string {
|
||||
if mapped, ok := providerMapping[strings.ToLower(provider)]; ok {
|
||||
return mapped
|
||||
}
|
||||
return strings.ToLower(provider)
|
||||
}
|
||||
247
pkg/migrate/sources/openclaw/openclaw_handler_test.go
Normal file
247
pkg/migrate/sources/openclaw/openclaw_handler_test.go
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
package openclaw
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewOpenclawHandler(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler, err := NewOpenclawHandler(Options{
|
||||
SourceHome: tmpDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, handler)
|
||||
}
|
||||
|
||||
func TestNewOpenclawHandlerNoConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
_, err := NewOpenclawHandler(Options{
|
||||
SourceHome: tmpDir,
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestOpenclawHandlerGetSourceName(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler, err := NewOpenclawHandler(Options{
|
||||
SourceHome: tmpDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "openclaw", handler.GetSourceName())
|
||||
}
|
||||
|
||||
func TestOpenclawHandlerGetSourceHome(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler, err := NewOpenclawHandler(Options{
|
||||
SourceHome: tmpDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
home, err := handler.GetSourceHome()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tmpDir, home)
|
||||
}
|
||||
|
||||
func TestOpenclawHandlerGetSourceWorkspace(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler, err := NewOpenclawHandler(Options{
|
||||
SourceHome: tmpDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
workspace, err := handler.GetSourceWorkspace()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(tmpDir, "workspace"), workspace)
|
||||
}
|
||||
|
||||
func TestOpenclawHandlerGetSourceConfigFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler, err := NewOpenclawHandler(Options{
|
||||
SourceHome: tmpDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
configFile, err := handler.GetSourceConfigFile()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, configPath, configFile)
|
||||
}
|
||||
|
||||
func TestOpenclawHandlerGetSourceConfigFileWithConfigJson(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler, err := NewOpenclawHandler(Options{
|
||||
SourceHome: tmpDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
configFile, err := handler.GetSourceConfigFile()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, configPath, configFile)
|
||||
}
|
||||
|
||||
func TestOpenclawHandlerGetMigrateableFiles(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler, err := NewOpenclawHandler(Options{
|
||||
SourceHome: tmpDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
files := handler.GetMigrateableFiles()
|
||||
assert.NotEmpty(t, files)
|
||||
assert.Contains(t, files, "AGENTS.md")
|
||||
assert.Contains(t, files, "SOUL.md")
|
||||
assert.Contains(t, files, "USER.md")
|
||||
}
|
||||
|
||||
func TestOpenclawHandlerGetMigrateableDirs(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler, err := NewOpenclawHandler(Options{
|
||||
SourceHome: tmpDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
dirs := handler.GetMigrateableDirs()
|
||||
assert.NotEmpty(t, dirs)
|
||||
assert.Contains(t, dirs, "memory")
|
||||
assert.Contains(t, dirs, "skills")
|
||||
}
|
||||
|
||||
func TestResolveSourceHome(t *testing.T) {
|
||||
result, err := resolveSourceHome("/custom/path")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/custom/path", result)
|
||||
}
|
||||
|
||||
func TestResolveSourceHomeWithEnvVar(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", "/env/path")
|
||||
|
||||
result, err := resolveSourceHome("")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/env/path", result)
|
||||
}
|
||||
|
||||
func TestResolveSourceHomeWithTilde(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := resolveSourceHome("~/openclaw")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(home, "openclaw"), result)
|
||||
}
|
||||
|
||||
func TestFindSourceConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := findSourceConfig(tmpDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, configPath, result)
|
||||
}
|
||||
|
||||
func TestFindSourceConfigWithConfigJson(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := findSourceConfig(tmpDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, configPath, result)
|
||||
}
|
||||
|
||||
func TestFindSourceConfigNotFound(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
_, err := findSourceConfig(tmpDir)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no config file found")
|
||||
}
|
||||
|
||||
func TestMapProvider(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"anthropic", "anthropic"},
|
||||
{"claude", "anthropic"},
|
||||
{"openai", "openai"},
|
||||
{"gpt", "openai"},
|
||||
{"groq", "groq"},
|
||||
{"ollama", "ollama"},
|
||||
{"openrouter", "openrouter"},
|
||||
{"deepseek", "deepseek"},
|
||||
{"together", "together"},
|
||||
{"mistral", "mistral"},
|
||||
{"fireworks", "fireworks"},
|
||||
{"google", "google"},
|
||||
{"gemini", "google"},
|
||||
{"xai", "xai"},
|
||||
{"grok", "xai"},
|
||||
{"cerebras", "cerebras"},
|
||||
{"sambanova", "sambanova"},
|
||||
{"unknown", "unknown"},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := mapProvider(tt.input)
|
||||
assert.Equal(t, tt.expected, result, "mapProvider(%q)", tt.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteWorkspacePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"~/.openclaw/workspace", "~/.picoclaw/workspace"},
|
||||
{"/home/user/.openclaw/workspace", "/home/user/.picoclaw/workspace"},
|
||||
{"/path/without/openclaw/change", "/path/without/openclaw/change"},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := rewriteWorkspacePath(tt.input)
|
||||
assert.Equal(t, tt.expected, result, "rewriteWorkspacePath(%q)", tt.input)
|
||||
}
|
||||
}
|
||||
|
|
@ -212,14 +212,14 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
|||
}
|
||||
|
||||
func parseResponse(resp *anthropic.Message) *LLMResponse {
|
||||
var content string
|
||||
var content strings.Builder
|
||||
var toolCalls []ToolCall
|
||||
|
||||
for _, block := range resp.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
tb := block.AsText()
|
||||
content += tb.Text
|
||||
content.WriteString(tb.Text)
|
||||
case "tool_use":
|
||||
tu := block.AsToolUse()
|
||||
var args map[string]any
|
||||
|
|
@ -246,7 +246,7 @@ func parseResponse(resp *anthropic.Message) *LLMResponse {
|
|||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: content,
|
||||
Content: content.String(),
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: finishReason,
|
||||
Usage: &UsageInfo{
|
||||
|
|
@ -264,8 +264,8 @@ func normalizeBaseURL(apiBase string) string {
|
|||
}
|
||||
|
||||
base = strings.TrimRight(base, "/")
|
||||
if strings.HasSuffix(base, "/v1") {
|
||||
base = strings.TrimSuffix(base, "/v1")
|
||||
if before, ok := strings.CutSuffix(base, "/v1"); ok {
|
||||
base = before
|
||||
}
|
||||
if base == "" {
|
||||
return defaultBaseURL
|
||||
|
|
|
|||
|
|
@ -163,8 +163,8 @@ func resolveCodexModel(model string) (string, string) {
|
|||
return codexDefaultModel, "empty model"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(m, "openai/") {
|
||||
m = strings.TrimPrefix(m, "openai/")
|
||||
if after, ok := strings.CutPrefix(m, "openai/"); ok {
|
||||
m = after
|
||||
} else if strings.Contains(m, "/") {
|
||||
return codexDefaultModel, "non-openai model namespace"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ func TestCooldown_FailureWindowReset(t *testing.T) {
|
|||
ct, current := newTestTracker(now)
|
||||
|
||||
// 4 errors → 1h cooldown
|
||||
for i := 0; i < 4; i++ {
|
||||
for range 4 {
|
||||
ct.MarkFailure("openai", FailoverRateLimit)
|
||||
*current = current.Add(2 * time.Second) // small advance between errors
|
||||
}
|
||||
|
|
@ -230,7 +230,7 @@ func TestCooldown_ConcurrentAccess(t *testing.T) {
|
|||
ct := NewCooldownTracker()
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
for range 100 {
|
||||
wg.Add(3)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// Common patterns in Go HTTP error messages
|
||||
var httpStatusPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`status[:\s]+(\d{3})`),
|
||||
regexp.MustCompile(`http[/\s]+\d*\.?\d*\s+(\d{3})`),
|
||||
regexp.MustCompile(`\b([3-5]\d{2})\b`),
|
||||
}
|
||||
|
||||
// errorPattern defines a single pattern (string or regex) for error classification.
|
||||
type errorPattern struct {
|
||||
substring string
|
||||
|
|
@ -198,20 +205,13 @@ func classifyByMessage(msg string) FailoverReason {
|
|||
}
|
||||
|
||||
// extractHTTPStatus extracts an HTTP status code from an error message.
|
||||
// Looks for patterns like "status: 429", "status 429", "HTTP 429", or standalone "429".
|
||||
// Looks for patterns like "status: 429", "status 429", "http/1.1 429", "http 429", or standalone "429".
|
||||
func extractHTTPStatus(msg string) int {
|
||||
// Common patterns in Go HTTP error messages
|
||||
patterns := []*regexp.Regexp{
|
||||
regexp.MustCompile(`status[:\s]+(\d{3})`),
|
||||
regexp.MustCompile(`HTTP[/\s]+\d*\.?\d*\s+(\d{3})`),
|
||||
}
|
||||
|
||||
for _, p := range patterns {
|
||||
for _, p := range httpStatusPatterns {
|
||||
if m := p.FindStringSubmatch(msg); len(m) > 1 {
|
||||
return parseDigits(m[1])
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -305,7 +305,8 @@ func TestExtractHTTPStatus(t *testing.T) {
|
|||
}{
|
||||
{"status: 429 rate limited", 429},
|
||||
{"status 401 unauthorized", 401},
|
||||
{"HTTP/1.1 502 Bad Gateway", 502},
|
||||
{"http/1.1 502 bad gateway", 502},
|
||||
{"error 429", 429},
|
||||
{"no status code here", 0},
|
||||
{"random number 12345", 0},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi
|
|||
|
||||
switch connectMode {
|
||||
case "stdio":
|
||||
// TODO:
|
||||
return nil, fmt.Errorf("stdio mode not implemented")
|
||||
// TODO: Implement stdio mode for GitHub Copilot provider
|
||||
// See https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md for details
|
||||
return nil, fmt.Errorf("stdio mode not implemented for GitHub Copilot provider; please use 'grpc' mode instead")
|
||||
case "grpc":
|
||||
client := copilot.NewClient(&copilot.ClientOptions{
|
||||
CLIUrl: uri,
|
||||
|
|
@ -100,9 +101,12 @@ func (p *GitHubCopilotProvider) Chat(
|
|||
return nil, fmt.Errorf("provider closed")
|
||||
}
|
||||
|
||||
resp, _ := session.SendAndWait(ctx, copilot.MessageOptions{
|
||||
resp, err := session.SendAndWait(ctx, copilot.MessageOptions{
|
||||
Prompt: string(fullcontent),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send message to copilot: %w", err)
|
||||
}
|
||||
|
||||
if resp == nil {
|
||||
return nil, fmt.Errorf("empty response from copilot")
|
||||
|
|
|
|||
|
|
@ -202,6 +202,10 @@ func (p *Provider) buildHTTPRequest(
|
|||
|
||||
// Prompt caching: pass a stable cache key so OpenAI can bucket requests
|
||||
// with the same key and reuse prefix KV cache across calls.
|
||||
// The key is typically the agent ID -- stable per agent, shared across requests.
|
||||
// See: https://platform.openai.com/docs/guides/prompt-caching
|
||||
// Prompt caching is only supported by OpenAI-native endpoints.
|
||||
// Gemini and other providers reject unknown fields, so skip for non-OpenAI APIs.
|
||||
if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
|
||||
if !strings.Contains(p.apiBase, "generativelanguage.googleapis.com") {
|
||||
requestBody["prompt_cache_key"] = cacheKey
|
||||
|
|
@ -581,8 +585,8 @@ func stripSystemParts(messages []Message) []openaiMessage {
|
|||
}
|
||||
|
||||
func normalizeModel(model, apiBase string) string {
|
||||
idx := strings.Index(model, "/")
|
||||
if idx == -1 {
|
||||
before, after, ok := strings.Cut(model, "/")
|
||||
if !ok {
|
||||
return model
|
||||
}
|
||||
|
||||
|
|
@ -590,20 +594,10 @@ func normalizeModel(model, apiBase string) string {
|
|||
return model
|
||||
}
|
||||
|
||||
prefix := strings.ToLower(model[:idx])
|
||||
prefix := strings.ToLower(before)
|
||||
switch prefix {
|
||||
case "openai",
|
||||
"moonshot",
|
||||
"nvidia",
|
||||
"groq",
|
||||
"ollama",
|
||||
"deepseek",
|
||||
"google",
|
||||
"openrouter",
|
||||
"zhipu",
|
||||
"minimax",
|
||||
"mistral":
|
||||
return model[idx+1:]
|
||||
case "openai", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "minimax", "mistral":
|
||||
return after
|
||||
default:
|
||||
return model
|
||||
}
|
||||
|
|
|
|||
|
|
@ -740,3 +740,24 @@ func TestProvider_RequestTimeoutNonPositive(t *testing.T) {
|
|||
t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_FunctionalOptionMaxTokensField(t *testing.T) {
|
||||
p := NewProvider("key", "https://example.com/v1", "", WithMaxTokensField("max_completion_tokens"))
|
||||
if p.maxTokensField != "max_completion_tokens" {
|
||||
t.Fatalf("maxTokensField = %q, want %q", p.maxTokensField, "max_completion_tokens")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_FunctionalOptionRequestTimeout(t *testing.T) {
|
||||
p := NewProvider("key", "https://example.com/v1", "", WithRequestTimeout(45*time.Second))
|
||||
if p.httpClient.Timeout != 45*time.Second {
|
||||
t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, 45*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_FunctionalOptionRequestTimeoutNonPositive(t *testing.T) {
|
||||
p := NewProvider("key", "https://example.com/v1", "", WithRequestTimeout(-1*time.Second))
|
||||
if p.httpClient.Timeout != defaultRequestTimeout {
|
||||
t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package routing
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeAgentID_Empty(t *testing.T) {
|
||||
if got := NormalizeAgentID(""); got != DefaultAgentID {
|
||||
|
|
@ -57,11 +60,11 @@ func TestNormalizeAgentID_AllInvalid(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestNormalizeAgentID_TruncatesAt64(t *testing.T) {
|
||||
long := ""
|
||||
for i := 0; i < 100; i++ {
|
||||
long += "a"
|
||||
var long strings.Builder
|
||||
for range 100 {
|
||||
long.WriteString("a")
|
||||
}
|
||||
got := NormalizeAgentID(long)
|
||||
got := NormalizeAgentID(long.String())
|
||||
if len(got) > MaxAgentIDLength {
|
||||
t.Errorf("length = %d, want <= %d", len(got), MaxAgentIDLength)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package skills
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -18,14 +17,6 @@ type SkillInstaller struct {
|
|||
workspace string
|
||||
}
|
||||
|
||||
type AvailableSkill struct {
|
||||
Name string `json:"name"`
|
||||
Repository string `json:"repository"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
func NewSkillInstaller(workspace string) *SkillInstaller {
|
||||
return &SkillInstaller{
|
||||
workspace: workspace,
|
||||
|
|
@ -89,35 +80,3 @@ func (si *SkillInstaller) Uninstall(skillName string) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableSkill, error) {
|
||||
url := "https://raw.githubusercontent.com/sipeed/picoclaw-skills/main/skills.json"
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := utils.DoRequestWithRetry(client, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch skills list: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("failed to fetch skills list: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var skills []AvailableSkill
|
||||
if err := json.Unmarshal(body, &skills); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse skills list: %w", err)
|
||||
}
|
||||
|
||||
return skills, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@ func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
|
|||
normalized := strings.ReplaceAll(content, "\r\n", "\n")
|
||||
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
||||
|
||||
for _, line := range strings.Split(normalized, "\n") {
|
||||
for line := range strings.SplitSeq(normalized, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue