Merge branch 'sipeed:main' into main
This commit is contained in:
commit
223dd663f7
85 changed files with 7700 additions and 1371 deletions
|
|
@ -5,6 +5,7 @@
|
|||
# ANTHROPIC_API_KEY=sk-ant-xxx
|
||||
# OPENAI_API_KEY=sk-xxx
|
||||
# GEMINI_API_KEY=xxx
|
||||
# MODELSCOPE_API_KEY=xxx
|
||||
# CLAUDE_CODE_OAUTH=xxx
|
||||
# ── Chat Channel ──────────────────────────
|
||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||
|
|
|
|||
27
.github/dependabot.yml
vendored
Normal file
27
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
version: 2
|
||||
|
||||
updates:
|
||||
|
||||
# Go dependencies (entire repo)
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "go"
|
||||
|
||||
# Frontend dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/web/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "frontend"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
|
@ -27,6 +27,7 @@ builds:
|
|||
- windows
|
||||
- darwin
|
||||
- freebsd
|
||||
- netbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
|
|
@ -44,6 +45,12 @@ builds:
|
|||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: netbsd
|
||||
goarch: s390x
|
||||
- goos: netbsd
|
||||
goarch: mips64
|
||||
- goos: netbsd
|
||||
goarch: arm
|
||||
|
||||
- id: picoclaw-launcher
|
||||
binary: picoclaw-launcher
|
||||
|
|
@ -58,6 +65,7 @@ builds:
|
|||
- windows
|
||||
- darwin
|
||||
- freebsd
|
||||
- netbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
|
|
@ -75,6 +83,12 @@ builds:
|
|||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: netbsd
|
||||
goarch: s390x
|
||||
- goos: netbsd
|
||||
goarch: mips64
|
||||
- goos: netbsd
|
||||
goarch: arm
|
||||
|
||||
- id: picoclaw-launcher-tui
|
||||
binary: picoclaw-launcher-tui
|
||||
|
|
@ -89,6 +103,7 @@ builds:
|
|||
- windows
|
||||
- darwin
|
||||
- freebsd
|
||||
- netbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
|
|
@ -106,6 +121,12 @@ builds:
|
|||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: netbsd
|
||||
goarch: s390x
|
||||
- goos: netbsd
|
||||
goarch: mips64
|
||||
- goos: netbsd
|
||||
goarch: arm
|
||||
|
||||
dockers_v2:
|
||||
- id: picoclaw
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -181,6 +181,8 @@ build-all: generate
|
|||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
||||
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||
GOOS=netbsd GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
|
||||
GOOS=netbsd GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
|
||||
@echo "All builds complete"
|
||||
|
||||
## install: Install picoclaw to system and copy builtin skills
|
||||
|
|
|
|||
19
README.fr.md
19
README.fr.md
|
|
@ -4,14 +4,18 @@
|
|||
<h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1>
|
||||
|
||||
<h3>Matériel à 10$ · 10 Mo de RAM · Démarrage en 1s · 皮皮虾,我们走!</h3>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||
<br>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) | **Français**
|
||||
|
|
@ -206,7 +210,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
|||
### 🚀 Démarrage Rapide
|
||||
|
||||
> [!TIP]
|
||||
> Configurez votre clé API dans `~/.picoclaw/config.json`. Obtenez des clés API : [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). La recherche web est optionnelle — obtenez gratuitement l'[API Tavily](https://tavily.com) (1000 requêtes gratuites/mois) ou l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois).
|
||||
> Configurez votre clé API dans `~/.picoclaw/config.json`. Obtenez des clés API : [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). La recherche web est optionnelle — obtenez gratuitement l'[API Tavily](https://tavily.com) (1000 requêtes gratuites/mois) ou l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois).
|
||||
|
||||
**1. Initialiser**
|
||||
|
||||
|
|
@ -222,7 +226,8 @@ picoclaw onboard
|
|||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-your-api-key"
|
||||
"api_key": "sk-your-api-key",
|
||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
|
|
@ -835,6 +840,7 @@ Le sous-agent a accès aux outils (message, web_search, etc.) et peut communique
|
|||
| ------------------------ | ---------------------------------------- | ------------------------------------------------------ |
|
||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
|
||||
| `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| `openrouter` (À tester) | LLM (recommandé, accès à tous les modèles) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` (À tester) | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` (À tester) | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||
|
|
@ -980,10 +986,12 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
|||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obtenir Clé](https://www.byteplus.com/) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obtenir une clé](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Obtenir un Token](https://modelscope.cn/my/tokens) |
|
||||
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [Obtenir Clé](https://portal.azure.com) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
||||
|
|
@ -1222,6 +1230,7 @@ Cela se produit lorsqu'une autre instance du bot est en cours d'exécution. Assu
|
|||
| **Zhipu** | 200K tokens/mois | Convient aux utilisateurs chinois |
|
||||
| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web |
|
||||
| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) |
|
||||
| **ModelScope** | 2000 requêtes/jour | Inférence gratuite (Qwen, GLM, DeepSeek, etc.) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
32
README.ja.md
32
README.ja.md
|
|
@ -5,12 +5,19 @@
|
|||
|
||||
<h3>$10 ハードウェア · 10MB RAM · 1秒起動 · 行くぜ、シャコ!</h3>
|
||||
<h3></h3>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
</p>
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||
<br>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
|
||||
|
||||
|
|
@ -168,7 +175,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
|||
### 🚀 クイックスタート(ネイティブ)
|
||||
|
||||
> [!TIP]
|
||||
> `~/.picoclaw/config.json` に API キーを設定してください。API キーの取得先: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。Web 検索は **任意** です — 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)。
|
||||
> `~/.picoclaw/config.json` に API キーを設定してください。API キーの取得先: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。Web 検索は **任意** です — 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)。
|
||||
|
||||
**1. 初期化**
|
||||
|
||||
|
|
@ -184,7 +191,8 @@ picoclaw onboard
|
|||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-your-api-key"
|
||||
"api_key": "sk-your-api-key",
|
||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
|
|
@ -793,6 +801,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
| --- | --- | --- |
|
||||
| `gemini` | LLM(Gemini 直接) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM(Zhipu 直接) | [bigmodel.cn](https://bigmodel.cn) |
|
||||
| `volcengine` | LLM(Volcengine 直接) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| `openrouter`(要テスト) | LLM(推奨、全モデルにアクセス可能) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic`(要テスト) | LLM(Claude 直接) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai`(要テスト) | LLM(GPT 直接) | [platform.openai.com](https://platform.openai.com) |
|
||||
|
|
@ -921,10 +930,12 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [キーを取得](https://www.byteplus.com) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [キーを取得](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [トークンを取得](https://modelscope.cn/my/tokens) |
|
||||
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [キーを取得](https://portal.azure.com) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
||||
|
|
@ -1145,6 +1156,7 @@ Web 検索を有効にするには:
|
|||
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
|
||||
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
||||
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
||||
| **ModelScope** | 1 日 2000 リクエスト | 無料推論(Qwen, GLM, DeepSeek など) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
49
README.md
49
README.md
|
|
@ -4,15 +4,16 @@
|
|||
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
|
||||
|
||||
<h3>$10 Hardware · 10MB RAM · 1s Boot · 皮皮虾,我们走!</h3>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||
<br>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
|
@ -227,7 +228,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
|||
### 🚀 Quick Start
|
||||
|
||||
> [!TIP]
|
||||
> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month).
|
||||
> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month).
|
||||
|
||||
**1. Initialize**
|
||||
|
||||
|
|
@ -252,7 +253,8 @@ picoclaw onboard
|
|||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-your-api-key"
|
||||
"api_key": "sk-your-api-key",
|
||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
|
|
@ -992,17 +994,19 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
|||
> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level.
|
||||
|
||||
| Provider | Purpose | Get API Key |
|
||||
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
|
||||
| ------------ | --------------------------------------- | ------------------------------------------------------------ |
|
||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
|
||||
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
|
||||
| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) |
|
||||
| `azure` | LLM (Azure OpenAI) | [portal.azure.com](https://portal.azure.com) |
|
||||
|
||||
### Model Configuration (model_list)
|
||||
|
||||
|
|
@ -1033,11 +1037,13 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) |
|
||||
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
|
||||
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [Get Key](https://portal.azure.com) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
||||
|
|
@ -1129,6 +1135,26 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
|
||||
> Run `picoclaw auth login --provider anthropic` to paste your API token.
|
||||
|
||||
**Anthropic Messages API (native format)**
|
||||
|
||||
For direct Anthropic API access or custom endpoints that only support Anthropic's native message format:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-opus-4-6",
|
||||
"model": "anthropic-messages/claude-opus-4-6",
|
||||
"api_key": "sk-ant-your-key",
|
||||
"api_base": "https://api.anthropic.com"
|
||||
}
|
||||
```
|
||||
|
||||
> Use `anthropic-messages` protocol when:
|
||||
> - Using third-party proxies that only support Anthropic's native `/v1/messages` endpoint (not OpenAI-compatible `/v1/chat/completions`)
|
||||
> - Connecting to services like MiniMax, Synthetic that require Anthropic's native message format
|
||||
> - The existing `anthropic` protocol returns 404 errors (indicating the endpoint doesn't support OpenAI-compatible format)
|
||||
>
|
||||
> **Note:** The `anthropic` protocol uses OpenAI-compatible format (`/v1/chat/completions`), while `anthropic-messages` uses Anthropic's native format (`/v1/messages`). Choose based on your endpoint's supported format.
|
||||
|
||||
**Ollama (local)**
|
||||
|
||||
```json
|
||||
|
|
@ -1525,6 +1551,7 @@ This happens when another instance of the bot is running. Make sure only one `pi
|
|||
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
||||
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
||||
| **LongCat** | Up to 5M tokens/day | Fast inference (free tier) |
|
||||
| **ModelScope** | 2000 requests/day | Free inference (Qwen, GLM, DeepSeek, etc.) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -4,14 +4,18 @@
|
|||
<h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>
|
||||
|
||||
<h3>Hardware de $10 · 10MB de RAM · Boot em 1s · 皮皮虾,我们走!</h3>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||
<br>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
|
||||
|
|
@ -207,7 +211,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
|||
### 🚀 Início Rápido
|
||||
|
||||
> [!TIP]
|
||||
> Configure sua API key em `~/.picoclaw/config.json`. Obtenha API keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Busca web é **opcional** — obtenha a [API Tavily](https://tavily.com) gratuita (1000 consultas grátis/mês) ou a [Brave Search API](https://brave.com/search/api) (2000 consultas grátis/mês).
|
||||
> Configure sua API key em `~/.picoclaw/config.json`. Obtenha API keys: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Busca web é **opcional** — obtenha a [API Tavily](https://tavily.com) gratuita (1000 consultas grátis/mês) ou a [Brave Search API](https://brave.com/search/api) (2000 consultas grátis/mês).
|
||||
|
||||
**1. Inicializar**
|
||||
|
||||
|
|
@ -223,7 +227,8 @@ picoclaw onboard
|
|||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-your-api-key"
|
||||
"api_key": "sk-your-api-key",
|
||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
|
|
@ -831,6 +836,7 @@ O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se com
|
|||
| --- | --- | --- |
|
||||
| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](bigmodel.cn) |
|
||||
| `volcengine` | LLM(Volcengine direto) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| `openrouter` (Em teste) | LLM (recomendado, acesso a todos os modelos) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) |
|
||||
|
|
@ -976,10 +982,12 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
|||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obter Chave](https://www.byteplus.com) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obter Chave](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Obter Token](https://modelscope.cn/my/tokens) |
|
||||
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [Obter Chave](https://portal.azure.com) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
||||
|
|
@ -1219,6 +1227,7 @@ Isso acontece quando outra instância do bot está em execução. Certifique-se
|
|||
| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web |
|
||||
| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) |
|
||||
| **Cerebras** | Plano gratuito disponível | Inferência ultra-rápida (Llama 3.3 70B) |
|
||||
| **ModelScope** | 2000 requisições/dia | Inferência gratuita (Qwen, GLM, DeepSeek, etc.) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
19
README.vi.md
19
README.vi.md
|
|
@ -4,14 +4,18 @@
|
|||
<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 · 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">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||
<br>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [English](README.md)
|
||||
|
|
@ -187,7 +191,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
|||
### 🚀 Bắt đầu nhanh
|
||||
|
||||
> [!TIP]
|
||||
> Thiết lập API key trong `~/.picoclaw/config.json`. Lấy API key: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Tìm kiếm web là **tùy chọn** — lấy [Tavily API](https://tavily.com) miễn phí (1000 truy vấn/tháng) hoặc [Brave Search API](https://brave.com/search/api) (2000 truy vấn/tháng).
|
||||
> Thiết lập API key trong `~/.picoclaw/config.json`. Lấy API key: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Tìm kiếm web là **tùy chọn** — lấy [Tavily API](https://tavily.com) miễn phí (1000 truy vấn/tháng) hoặc [Brave Search API](https://brave.com/search/api) (2000 truy vấn/tháng).
|
||||
|
||||
**1. Khởi tạo**
|
||||
|
||||
|
|
@ -203,7 +207,8 @@ picoclaw onboard
|
|||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-your-api-key"
|
||||
"api_key": "sk-your-api-key",
|
||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
|
|
@ -803,6 +808,7 @@ Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và
|
|||
| --- | --- | --- |
|
||||
| `gemini` | LLM (Gemini trực tiếp) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (Zhipu trực tiếp) | [bigmodel.cn](bigmodel.cn) |
|
||||
| `volcengine` | LLM(Volcengine trực tiếp) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| `openrouter` (Đang thử nghiệm) | LLM (khuyên dùng, truy cập mọi model) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` (Đang thử nghiệm) | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` (Đang thử nghiệm) | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) |
|
||||
|
|
@ -945,10 +951,12 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
|||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Lấy Khóa](https://www.byteplus.com) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Lấy Key](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Lấy Token](https://modelscope.cn/my/tokens) |
|
||||
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [Lấy Khóa](https://portal.azure.com) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
||||
|
|
@ -1187,6 +1195,7 @@ Một số nhà cung cấp (như Zhipu) có bộ lọc nội dung nghiêm ngặt
|
|||
| **Zhipu** | 200K tokens/tháng | Phù hợp cho người dùng Trung Quốc |
|
||||
| **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web |
|
||||
| **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) |
|
||||
| **ModelScope** | 2000 yêu cầu/ngày | Suy luận miễn phí (Qwen, GLM, DeepSeek, v.v.) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
47
README.zh.md
47
README.zh.md
|
|
@ -4,14 +4,18 @@
|
|||
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
||||
|
||||
<h3>10$硬件 · 10MB内存 · 1秒启动 · 皮皮虾,我们走!</h3>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||
<br>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
|
||||
|
|
@ -208,7 +212,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
|||
### 🚀 快速开始
|
||||
|
||||
> [!TIP]
|
||||
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。获取 API Key: [火山引擎 (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。网络搜索是 **可选的** — 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)。
|
||||
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。获取 API Key: [火山引擎 (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。网络搜索是 **可选的** — 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)。
|
||||
|
||||
**1. 初始化 (Initialize)**
|
||||
|
||||
|
|
@ -234,7 +238,8 @@ picoclaw onboard
|
|||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-your-api-key"
|
||||
"api_key": "sk-your-api-key",
|
||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
|
|
@ -481,10 +486,11 @@ Agent 读取 HEARTBEAT.md
|
|||
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
||||
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
|
||||
| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `volcengine` | LLM (火山引擎直连) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| `openrouter` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
|
||||
|
|
@ -517,10 +523,12 @@ Agent 读取 HEARTBEAT.md
|
|||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
|
||||
| **火山引擎(Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
|
||||
| **火山引擎(Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://www.byteplus.com) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取 Token](https://modelscope.cn/my/tokens) |
|
||||
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [获取密钥](https://portal.azure.com) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
||||
|
|
@ -612,6 +620,26 @@ Agent 读取 HEARTBEAT.md
|
|||
|
||||
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
|
||||
|
||||
**Anthropic Messages API(原生格式)**
|
||||
|
||||
用于直接访问 Anthropic API 或仅支持 Anthropic 原生消息格式的自定义端点:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-opus-4-6",
|
||||
"model": "anthropic-messages/claude-opus-4-6",
|
||||
"api_key": "sk-ant-your-key",
|
||||
"api_base": "https://api.anthropic.com"
|
||||
}
|
||||
```
|
||||
|
||||
> 使用 `anthropic-messages` 协议的场景:
|
||||
> - 使用仅支持 Anthropic 原生 `/v1/messages` 端点的第三方代理(不支持 OpenAI 兼容的 `/v1/chat/completions`)
|
||||
> - 连接到 MiniMax、Synthetic 等需要 Anthropic 原生消息格式的服务
|
||||
> - 现有的 `anthropic` 协议返回 404 错误(说明端点不支持 OpenAI 兼容格式)
|
||||
>
|
||||
> **注意:** `anthropic` 协议使用 OpenAI 兼容格式(`/v1/chat/completions`),而 `anthropic-messages` 使用 Anthropic 原生格式(`/v1/messages`)。请根据端点支持的格式选择。
|
||||
|
||||
**Ollama (本地)**
|
||||
|
||||
```json
|
||||
|
|
@ -900,6 +928,7 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
|||
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
|
||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
||||
| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) |
|
||||
| **ModelScope (魔搭)** | 2000 次请求/天 | 免费推理 (Qwen, GLM, DeepSeek 等) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 345 KiB After Width: | Height: | Size: 93 KiB |
|
|
@ -9,7 +9,7 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/chzyer/readline"
|
||||
"github.com/ergochat/readline"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ package gateway
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
|
|
@ -41,12 +41,31 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/voice"
|
||||
)
|
||||
|
||||
// Timeout constants for service operations
|
||||
const (
|
||||
serviceRestartTimeout = 30 * time.Second
|
||||
serviceShutdownTimeout = 30 * time.Second
|
||||
providerReloadTimeout = 30 * time.Second
|
||||
gracefulShutdownTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// gatewayServices holds references to all running services
|
||||
type gatewayServices struct {
|
||||
CronService *cron.CronService
|
||||
HeartbeatService *heartbeat.HeartbeatService
|
||||
MediaStore media.MediaStore
|
||||
ChannelManager *channels.Manager
|
||||
DeviceService *devices.Service
|
||||
HealthServer *health.Server
|
||||
}
|
||||
|
||||
func gatewayCmd(debug bool) error {
|
||||
if debug {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
}
|
||||
|
||||
configPath := internal.GetConfigPath()
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading config: %w", err)
|
||||
|
|
@ -83,9 +102,55 @@ func gatewayCmd(debug bool) error {
|
|||
"skills_available": skillsInfo["available"],
|
||||
})
|
||||
|
||||
// Setup and start all services
|
||||
services, err := setupAndStartServices(cfg, agentLoop, msgBus)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
fmt.Println("Press Ctrl+C to stop")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go agentLoop.Run(ctx)
|
||||
|
||||
// Setup config file watcher for hot reload
|
||||
configReloadChan, stopWatch := setupConfigWatcherPolling(configPath, debug)
|
||||
defer stopWatch()
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt)
|
||||
|
||||
// Main event loop - wait for signals or config changes
|
||||
for {
|
||||
select {
|
||||
case <-sigChan:
|
||||
logger.Info("Shutting down...")
|
||||
shutdownGateway(services, agentLoop, provider, true)
|
||||
return nil
|
||||
|
||||
case newCfg := <-configReloadChan:
|
||||
err := handleConfigReload(ctx, agentLoop, newCfg, &provider, services, msgBus)
|
||||
if err != nil {
|
||||
logger.Errorf("Config reload failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setupAndStartServices initializes and starts all services
|
||||
func setupAndStartServices(
|
||||
cfg *config.Config,
|
||||
agentLoop *agent.AgentLoop,
|
||||
msgBus *bus.MessageBus,
|
||||
) (*gatewayServices, error) {
|
||||
services := &gatewayServices{}
|
||||
|
||||
// Setup cron tool and service
|
||||
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
||||
cronService := setupCronTool(
|
||||
services.CronService = setupCronTool(
|
||||
agentLoop,
|
||||
msgBus,
|
||||
cfg.WorkspacePath(),
|
||||
|
|
@ -93,20 +158,26 @@ func gatewayCmd(debug bool) error {
|
|||
execTimeout,
|
||||
cfg,
|
||||
)
|
||||
if err := services.CronService.Start(); err != nil {
|
||||
return nil, fmt.Errorf("error starting cron service: %w", err)
|
||||
}
|
||||
fmt.Println("✓ Cron service started")
|
||||
|
||||
heartbeatService := heartbeat.NewHeartbeatService(
|
||||
// Setup heartbeat service
|
||||
services.HeartbeatService = heartbeat.NewHeartbeatService(
|
||||
cfg.WorkspacePath(),
|
||||
cfg.Heartbeat.Interval,
|
||||
cfg.Heartbeat.Enabled,
|
||||
)
|
||||
heartbeatService.SetBus(msgBus)
|
||||
heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||
services.HeartbeatService.SetBus(msgBus)
|
||||
services.HeartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||
// Use cli:direct as fallback if no valid channel
|
||||
if channel == "" || chatID == "" {
|
||||
channel, chatID = "cli", "direct"
|
||||
}
|
||||
// Use ProcessHeartbeat - no session history, each heartbeat is independent
|
||||
var response string
|
||||
var err error
|
||||
response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
||||
if err != nil {
|
||||
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
||||
|
|
@ -118,24 +189,36 @@ func gatewayCmd(debug bool) error {
|
|||
// sent to user via processSystemMessage when the async task completes
|
||||
return tools.SilentResult(response)
|
||||
})
|
||||
if err := services.HeartbeatService.Start(); err != nil {
|
||||
return nil, fmt.Errorf("error starting heartbeat service: %w", err)
|
||||
}
|
||||
fmt.Println("✓ Heartbeat service started")
|
||||
|
||||
// Create media store for file lifecycle management with TTL cleanup
|
||||
mediaStore := media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
|
||||
services.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
|
||||
Enabled: cfg.Tools.MediaCleanup.Enabled,
|
||||
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
|
||||
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
|
||||
})
|
||||
mediaStore.Start()
|
||||
// Start the media store if it's a FileMediaStore with cleanup
|
||||
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
||||
fms.Start()
|
||||
}
|
||||
|
||||
channelManager, err := channels.NewManager(cfg, msgBus, mediaStore)
|
||||
// Create channel manager
|
||||
var err error
|
||||
services.ChannelManager, err = channels.NewManager(cfg, msgBus, services.MediaStore)
|
||||
if err != nil {
|
||||
mediaStore.Stop()
|
||||
return fmt.Errorf("error creating channel manager: %w", err)
|
||||
// Stop the media store if it's a FileMediaStore with cleanup
|
||||
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
||||
fms.Stop()
|
||||
}
|
||||
return nil, fmt.Errorf("error creating channel manager: %w", err)
|
||||
}
|
||||
|
||||
// Inject channel manager and media store into agent loop
|
||||
agentLoop.SetChannelManager(channelManager)
|
||||
agentLoop.SetMediaStore(mediaStore)
|
||||
agentLoop.SetChannelManager(services.ChannelManager)
|
||||
agentLoop.SetMediaStore(services.MediaStore)
|
||||
|
||||
// Wire up voice transcription if a supported provider is configured.
|
||||
if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
|
||||
|
|
@ -143,83 +226,386 @@ func gatewayCmd(debug bool) error {
|
|||
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
|
||||
}
|
||||
|
||||
enabledChannels := channelManager.GetEnabledChannels()
|
||||
enabledChannels := services.ChannelManager.GetEnabledChannels()
|
||||
if len(enabledChannels) > 0 {
|
||||
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
||||
} else {
|
||||
fmt.Println("⚠ Warning: No channels enabled")
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
fmt.Println("Press Ctrl+C to stop")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := cronService.Start(); err != nil {
|
||||
fmt.Printf("Error starting cron service: %v\n", err)
|
||||
}
|
||||
fmt.Println("✓ Cron service started")
|
||||
|
||||
if err := heartbeatService.Start(); err != nil {
|
||||
fmt.Printf("Error starting heartbeat service: %v\n", err)
|
||||
}
|
||||
fmt.Println("✓ Heartbeat service started")
|
||||
|
||||
stateManager := state.NewManager(cfg.WorkspacePath())
|
||||
deviceService := devices.NewService(devices.Config{
|
||||
Enabled: cfg.Devices.Enabled,
|
||||
MonitorUSB: cfg.Devices.MonitorUSB,
|
||||
}, stateManager)
|
||||
deviceService.SetBus(msgBus)
|
||||
if err := deviceService.Start(ctx); err != nil {
|
||||
fmt.Printf("Error starting device service: %v\n", err)
|
||||
} else if cfg.Devices.Enabled {
|
||||
fmt.Println("✓ Device event service started")
|
||||
}
|
||||
|
||||
// Setup shared HTTP server with health endpoints and webhook handlers
|
||||
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
channelManager.SetupHTTPServer(addr, healthServer)
|
||||
services.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
services.ChannelManager.SetupHTTPServer(addr, services.HealthServer)
|
||||
|
||||
if err := channelManager.StartAll(ctx); err != nil {
|
||||
fmt.Printf("Error starting channels: %v\n", err)
|
||||
return err
|
||||
if err := services.ChannelManager.StartAll(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("error starting channels: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
|
||||
go agentLoop.Run(ctx)
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt)
|
||||
<-sigChan
|
||||
|
||||
fmt.Println("\nShutting down...")
|
||||
if cp, ok := provider.(providers.StatefulProvider); ok {
|
||||
cp.Close()
|
||||
// Setup state manager and device service
|
||||
stateManager := state.NewManager(cfg.WorkspacePath())
|
||||
services.DeviceService = devices.NewService(devices.Config{
|
||||
Enabled: cfg.Devices.Enabled,
|
||||
MonitorUSB: cfg.Devices.MonitorUSB,
|
||||
}, stateManager)
|
||||
services.DeviceService.SetBus(msgBus)
|
||||
if err := services.DeviceService.Start(context.Background()); err != nil {
|
||||
logger.ErrorCF("device", "Error starting device service", map[string]any{"error": err.Error()})
|
||||
} else if cfg.Devices.Enabled {
|
||||
fmt.Println("✓ Device event service started")
|
||||
}
|
||||
cancel()
|
||||
msgBus.Close()
|
||||
|
||||
// Use a fresh context with timeout for graceful shutdown,
|
||||
// since the original ctx is already canceled.
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
return services, nil
|
||||
}
|
||||
|
||||
// stopAndCleanupServices stops all services and cleans up resources
|
||||
func stopAndCleanupServices(
|
||||
services *gatewayServices,
|
||||
shutdownTimeout time.Duration,
|
||||
) {
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer shutdownCancel()
|
||||
|
||||
channelManager.StopAll(shutdownCtx)
|
||||
deviceService.Stop()
|
||||
heartbeatService.Stop()
|
||||
cronService.Stop()
|
||||
mediaStore.Stop()
|
||||
if services.ChannelManager != nil {
|
||||
services.ChannelManager.StopAll(shutdownCtx)
|
||||
}
|
||||
if services.DeviceService != nil {
|
||||
services.DeviceService.Stop()
|
||||
}
|
||||
if services.HeartbeatService != nil {
|
||||
services.HeartbeatService.Stop()
|
||||
}
|
||||
if services.CronService != nil {
|
||||
services.CronService.Stop()
|
||||
}
|
||||
if services.MediaStore != nil {
|
||||
// Stop the media store if it's a FileMediaStore with cleanup
|
||||
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
||||
fms.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shutdownGateway performs a complete gateway shutdown
|
||||
func shutdownGateway(
|
||||
services *gatewayServices,
|
||||
agentLoop *agent.AgentLoop,
|
||||
provider providers.LLMProvider,
|
||||
fullShutdown bool,
|
||||
) {
|
||||
if cp, ok := provider.(providers.StatefulProvider); ok && fullShutdown {
|
||||
cp.Close()
|
||||
}
|
||||
|
||||
stopAndCleanupServices(services, gracefulShutdownTimeout)
|
||||
|
||||
agentLoop.Stop()
|
||||
agentLoop.Close()
|
||||
fmt.Println("✓ Gateway stopped")
|
||||
|
||||
logger.Info("✓ Gateway stopped")
|
||||
}
|
||||
|
||||
// handleConfigReload handles config file reload by stopping all services,
|
||||
// reloading the provider and config, and restarting services with the new config.
|
||||
func handleConfigReload(
|
||||
ctx context.Context,
|
||||
al *agent.AgentLoop,
|
||||
newCfg *config.Config,
|
||||
providerRef *providers.LLMProvider,
|
||||
services *gatewayServices,
|
||||
msgBus *bus.MessageBus,
|
||||
) error {
|
||||
logger.Info("🔄 Config file changed, reloading...")
|
||||
|
||||
newModel := newCfg.Agents.Defaults.ModelName
|
||||
if newModel == "" {
|
||||
newModel = newCfg.Agents.Defaults.Model
|
||||
}
|
||||
|
||||
logger.Infof(" New model is '%s', recreating provider...", newModel)
|
||||
|
||||
// Stop all services before reloading
|
||||
logger.Info(" Stopping all services...")
|
||||
stopAndCleanupServices(services, serviceShutdownTimeout)
|
||||
|
||||
// Create new provider from updated config first to ensure validity
|
||||
// This will use the correct API key and settings from newCfg.ModelList
|
||||
newProvider, newModelID, err := providers.CreateProvider(newCfg)
|
||||
if err != nil {
|
||||
logger.Errorf(" ⚠ Error creating new provider: %v", err)
|
||||
logger.Warn(" Attempting to restart services with old provider and config...")
|
||||
// Try to restart services with old configuration
|
||||
if restartErr := restartServices(al, services, msgBus); restartErr != nil {
|
||||
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
|
||||
}
|
||||
return fmt.Errorf("error creating new provider: %w", err)
|
||||
}
|
||||
|
||||
if newModelID != "" {
|
||||
newCfg.Agents.Defaults.ModelName = newModelID
|
||||
}
|
||||
|
||||
// Use the atomic reload method on AgentLoop to safely swap provider and config.
|
||||
// This handles locking internally to prevent races with in-flight LLM calls
|
||||
// and concurrent reads of registry/config while the swap occurs.
|
||||
reloadCtx, reloadCancel := context.WithTimeout(context.Background(), providerReloadTimeout)
|
||||
defer reloadCancel()
|
||||
|
||||
if err := al.ReloadProviderAndConfig(reloadCtx, newProvider, newCfg); err != nil {
|
||||
logger.Errorf(" ⚠ Error reloading agent loop: %v", err)
|
||||
// Close the newly created provider since it wasn't adopted
|
||||
if cp, ok := newProvider.(providers.StatefulProvider); ok {
|
||||
cp.Close()
|
||||
}
|
||||
logger.Warn(" Attempting to restart services with old provider and config...")
|
||||
if restartErr := restartServices(al, services, msgBus); restartErr != nil {
|
||||
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
|
||||
}
|
||||
return fmt.Errorf("error reloading agent loop: %w", err)
|
||||
}
|
||||
|
||||
// Update local provider reference only after successful atomic reload
|
||||
*providerRef = newProvider
|
||||
|
||||
// Restart all services with new config
|
||||
logger.Info(" Restarting all services with new configuration...")
|
||||
if err := restartServices(al, services, msgBus); err != nil {
|
||||
logger.Errorf(" ⚠ Error restarting services: %v", err)
|
||||
return fmt.Errorf("error restarting services: %w", err)
|
||||
}
|
||||
|
||||
logger.Info(" ✓ Provider, configuration, and services reloaded successfully (thread-safe)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// restartServices restarts all services after a config reload
|
||||
func restartServices(
|
||||
al *agent.AgentLoop,
|
||||
services *gatewayServices,
|
||||
msgBus *bus.MessageBus,
|
||||
) error {
|
||||
// Create an independent context with timeout for service restart
|
||||
// This prevents cancellation from the main loop context during reload
|
||||
ctx, cancel := context.WithTimeout(context.Background(), serviceRestartTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Get current config from agent loop (which has been updated if this is a reload)
|
||||
cfg := al.GetConfig()
|
||||
|
||||
// Re-create and start cron service with new config
|
||||
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
||||
services.CronService = setupCronTool(
|
||||
al,
|
||||
msgBus,
|
||||
cfg.WorkspacePath(),
|
||||
cfg.Agents.Defaults.RestrictToWorkspace,
|
||||
execTimeout,
|
||||
cfg,
|
||||
)
|
||||
if err := services.CronService.Start(); err != nil {
|
||||
return fmt.Errorf("error restarting cron service: %w", err)
|
||||
}
|
||||
fmt.Println(" ✓ Cron service restarted")
|
||||
|
||||
// Re-create and start heartbeat service with new config
|
||||
services.HeartbeatService = heartbeat.NewHeartbeatService(
|
||||
cfg.WorkspacePath(),
|
||||
cfg.Heartbeat.Interval,
|
||||
cfg.Heartbeat.Enabled,
|
||||
)
|
||||
services.HeartbeatService.SetBus(msgBus)
|
||||
services.HeartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||
if channel == "" || chatID == "" {
|
||||
channel, chatID = "cli", "direct"
|
||||
}
|
||||
var response string
|
||||
var err error
|
||||
response, err = al.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
||||
if err != nil {
|
||||
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
||||
}
|
||||
if response == "HEARTBEAT_OK" {
|
||||
return tools.SilentResult("Heartbeat OK")
|
||||
}
|
||||
return tools.SilentResult(response)
|
||||
})
|
||||
if err := services.HeartbeatService.Start(); err != nil {
|
||||
return fmt.Errorf("error restarting heartbeat service: %w", err)
|
||||
}
|
||||
fmt.Println(" ✓ Heartbeat service restarted")
|
||||
|
||||
// Stop the old media store before creating a new one
|
||||
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
||||
fms.Stop()
|
||||
}
|
||||
|
||||
// Re-create media store with new config
|
||||
services.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
|
||||
Enabled: cfg.Tools.MediaCleanup.Enabled,
|
||||
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
|
||||
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
|
||||
})
|
||||
// Start the media store if it's a FileMediaStore with cleanup
|
||||
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
||||
fms.Start()
|
||||
}
|
||||
al.SetMediaStore(services.MediaStore)
|
||||
|
||||
// Re-create channel manager with new config
|
||||
var err error
|
||||
services.ChannelManager, err = channels.NewManager(cfg, msgBus, services.MediaStore)
|
||||
if err != nil {
|
||||
// Stop the media store if it's a FileMediaStore with cleanup
|
||||
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
||||
fms.Stop()
|
||||
}
|
||||
return fmt.Errorf("error recreating channel manager: %w", err)
|
||||
}
|
||||
al.SetChannelManager(services.ChannelManager)
|
||||
|
||||
enabledChannels := services.ChannelManager.GetEnabledChannels()
|
||||
if len(enabledChannels) > 0 {
|
||||
fmt.Printf(" ✓ Channels enabled: %s\n", enabledChannels)
|
||||
} else {
|
||||
fmt.Println(" ⚠ Warning: No channels enabled")
|
||||
}
|
||||
|
||||
// Setup HTTP server with new config
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
services.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
services.ChannelManager.SetupHTTPServer(addr, services.HealthServer)
|
||||
|
||||
if err := services.ChannelManager.StartAll(ctx); err != nil {
|
||||
return fmt.Errorf("error restarting channels: %w", err)
|
||||
}
|
||||
fmt.Printf(
|
||||
" ✓ Channels restarted, health endpoints at http://%s:%d/health and ready\n",
|
||||
cfg.Gateway.Host,
|
||||
cfg.Gateway.Port,
|
||||
)
|
||||
|
||||
// Re-create device service with new config
|
||||
stateManager := state.NewManager(cfg.WorkspacePath())
|
||||
services.DeviceService = devices.NewService(devices.Config{
|
||||
Enabled: cfg.Devices.Enabled,
|
||||
MonitorUSB: cfg.Devices.MonitorUSB,
|
||||
}, stateManager)
|
||||
services.DeviceService.SetBus(msgBus)
|
||||
if err := services.DeviceService.Start(ctx); err != nil {
|
||||
logger.WarnCF("device", "Failed to restart device service", map[string]any{"error": err.Error()})
|
||||
} else if cfg.Devices.Enabled {
|
||||
fmt.Println(" ✓ Device event service restarted")
|
||||
}
|
||||
|
||||
// Wire up voice transcription with new config
|
||||
transcriber := voice.DetectTranscriber(cfg)
|
||||
al.SetTranscriber(transcriber) // This will set it to nil if disabled
|
||||
if transcriber != nil {
|
||||
logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
|
||||
} else {
|
||||
logger.InfoCF("voice", "Transcription disabled", nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupConfigWatcherPolling sets up a simple polling-based config file watcher
|
||||
// Returns a channel for config updates and a stop function
|
||||
func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Config, func()) {
|
||||
configChan := make(chan *config.Config, 1)
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
// Get initial file info
|
||||
lastModTime := getFileModTime(configPath)
|
||||
lastSize := getFileSize(configPath)
|
||||
|
||||
ticker := time.NewTicker(2 * time.Second) // Check every 2 seconds
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
currentModTime := getFileModTime(configPath)
|
||||
currentSize := getFileSize(configPath)
|
||||
|
||||
// Check if file changed (modification time or size changed)
|
||||
if currentModTime.After(lastModTime) || currentSize != lastSize {
|
||||
if debug {
|
||||
logger.Debugf("🔍 Config file change detected")
|
||||
}
|
||||
|
||||
// Debounce - wait a bit to ensure file write is complete
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Validate and load new config
|
||||
newCfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
logger.Errorf("⚠ Error loading new config: %v", err)
|
||||
logger.Warn(" Using previous valid config")
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate the new config
|
||||
if err := newCfg.ValidateModelList(); err != nil {
|
||||
logger.Errorf(" ⚠ New config validation failed: %v", err)
|
||||
logger.Warn(" Using previous valid config")
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Info("✓ Config file validated and loaded")
|
||||
|
||||
// Update last known state
|
||||
lastModTime = currentModTime
|
||||
lastSize = currentSize
|
||||
|
||||
// Send new config to main loop (non-blocking)
|
||||
select {
|
||||
case configChan <- newCfg:
|
||||
default:
|
||||
// Channel full, skip this update
|
||||
logger.Warn("⚠ Previous config reload still in progress, skipping")
|
||||
}
|
||||
}
|
||||
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
stopFunc := func() {
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
return configChan, stopFunc
|
||||
}
|
||||
|
||||
// getFileModTime returns the modification time of a file, or zero time if file doesn't exist
|
||||
func getFileModTime(path string) time.Time {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return info.ModTime()
|
||||
}
|
||||
|
||||
// getFileSize returns the size of a file, or 0 if file doesn't exist
|
||||
func getFileSize(path string) int64 {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
func setupCronTool(
|
||||
agentLoop *agent.AgentLoop,
|
||||
msgBus *bus.MessageBus,
|
||||
|
|
@ -239,7 +625,7 @@ func setupCronTool(
|
|||
var err error
|
||||
cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Critical error during CronTool initialization: %v", err)
|
||||
logger.Fatalf("Critical error during CronTool initialization: %v", err)
|
||||
}
|
||||
|
||||
agentLoop.RegisterTool(cronTool)
|
||||
|
|
|
|||
138
cmd/picoclaw/internal/model/command.go
Normal file
138
cmd/picoclaw/internal/model/command.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// LocalModel is a special model name that indicates that the model is local and with or without api_key.
|
||||
const LocalModel = "local-model"
|
||||
|
||||
func NewModelCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "model [model_name]",
|
||||
Short: "Show or change the default model",
|
||||
Long: `Show or change the default model configuration.
|
||||
|
||||
If no argument is provided, shows the current default model.
|
||||
If a model name is provided, sets it as the default model.
|
||||
|
||||
Examples:
|
||||
picoclaw model # Show current default model
|
||||
picoclaw model gpt-5.2 # Set gpt-5.2 as default
|
||||
picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default
|
||||
picoclaw model local-model # Set local VLLM server as default
|
||||
|
||||
Note: 'local-model' is a special value for using a local VLLM server
|
||||
(running at localhost:8000 by default) which does not require an API key.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configPath := internal.GetConfigPath()
|
||||
|
||||
// Load current config
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
// Show current default model
|
||||
showCurrentModel(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set new default model
|
||||
modelName := args[0]
|
||||
return setDefaultModel(configPath, cfg, modelName)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func showCurrentModel(cfg *config.Config) {
|
||||
defaultModel := cfg.Agents.Defaults.ModelName
|
||||
if defaultModel == "" {
|
||||
defaultModel = cfg.Agents.Defaults.Model
|
||||
}
|
||||
|
||||
if defaultModel == "" {
|
||||
fmt.Println("No default model is currently set.")
|
||||
fmt.Println("\nAvailable models in your config:")
|
||||
listAvailableModels(cfg)
|
||||
} else {
|
||||
fmt.Printf("Current default model: %s\n", defaultModel)
|
||||
fmt.Println("\nAvailable models in your config:")
|
||||
listAvailableModels(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func listAvailableModels(cfg *config.Config) {
|
||||
if len(cfg.ModelList) == 0 {
|
||||
fmt.Println(" No models configured in model_list")
|
||||
return
|
||||
}
|
||||
|
||||
defaultModel := cfg.Agents.Defaults.ModelName
|
||||
if defaultModel == "" {
|
||||
defaultModel = cfg.Agents.Defaults.Model
|
||||
}
|
||||
|
||||
for _, model := range cfg.ModelList {
|
||||
marker := " "
|
||||
if model.ModelName == defaultModel {
|
||||
marker = "> "
|
||||
}
|
||||
if model.APIKey == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func setDefaultModel(configPath string, cfg *config.Config, modelName string) error {
|
||||
// Validate that the model exists in model_list
|
||||
modelFound := false
|
||||
for _, model := range cfg.ModelList {
|
||||
if model.APIKey != "" && model.ModelName == modelName {
|
||||
modelFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !modelFound && modelName != LocalModel {
|
||||
return fmt.Errorf("cannot found model '%s' in config", modelName)
|
||||
}
|
||||
|
||||
// Update the default model
|
||||
// Clear old model field and set new model_name
|
||||
oldModel := cfg.Agents.Defaults.ModelName
|
||||
if oldModel == "" {
|
||||
oldModel = cfg.Agents.Defaults.Model
|
||||
}
|
||||
|
||||
cfg.Agents.Defaults.ModelName = modelName
|
||||
cfg.Agents.Defaults.Model = "" // Clear deprecated field
|
||||
|
||||
// Save config back to file
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Default model changed from '%s' to '%s'\n",
|
||||
formatModelName(oldModel), modelName)
|
||||
fmt.Println("\nThe new default model will be used for all agent interactions.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatModelName(name string) string {
|
||||
if name == "" {
|
||||
return "(none)"
|
||||
}
|
||||
return name
|
||||
}
|
||||
369
cmd/picoclaw/internal/model/command_test.go
Normal file
369
cmd/picoclaw/internal/model/command_test.go
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
var configPath = ""
|
||||
|
||||
func initTest(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath = filepath.Join(tmpDir, "config.json")
|
||||
_ = os.Setenv("PICOCLAW_CONFIG", configPath)
|
||||
}
|
||||
|
||||
// captureStdout captures stdout during the execution of fn and returns the captured output
|
||||
func captureStdout(fn func()) string {
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
fn()
|
||||
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestNewModelCommand(t *testing.T) {
|
||||
cmd := NewModelCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "model [model_name]", cmd.Use)
|
||||
assert.Equal(t, "Show or change the default model", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRunE)
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
}
|
||||
|
||||
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "gpt-4",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
|
||||
{ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
|
||||
},
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
showCurrentModel(cfg)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "Current default model: gpt-4")
|
||||
assert.Contains(t, output, "Available models in your config:")
|
||||
assert.Contains(t, output, "gpt-4")
|
||||
assert.Contains(t, output, "claude-3")
|
||||
}
|
||||
|
||||
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "",
|
||||
Model: "",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
|
||||
},
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
showCurrentModel(cfg)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "No default model is currently set.")
|
||||
assert.Contains(t, output, "Available models in your config:")
|
||||
}
|
||||
|
||||
func TestShowCurrentModel_BackwardCompatibility(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Model: "legacy-model",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{},
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
showCurrentModel(cfg)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "Current default model: legacy-model")
|
||||
}
|
||||
|
||||
func TestListAvailableModels_Empty(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{},
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
listAvailableModels(cfg)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "No models configured in model_list")
|
||||
}
|
||||
|
||||
func TestListAvailableModels_WithModels(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "gpt-4",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
|
||||
{ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
|
||||
{ModelName: "no-key-model", Model: "openai/test", APIKey: ""},
|
||||
},
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
listAvailableModels(cfg)
|
||||
})
|
||||
|
||||
assert.NotEmpty(t, output)
|
||||
assert.Contains(t, output, "> - gpt-4 (openai/gpt-4)")
|
||||
assert.Contains(t, output, "claude-3 (anthropic/claude-3)")
|
||||
assert.NotContains(t, output, "no-key-model")
|
||||
}
|
||||
|
||||
func TestSetDefaultModel_ValidModel(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "old-model",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
|
||||
{ModelName: "old-model", Model: "openai/old-model", APIKey: "test"},
|
||||
},
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
err := setDefaultModel(configPath, cfg, "new-model")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'")
|
||||
|
||||
// Verify config was updated
|
||||
updatedCfg, err := config.LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new-model", updatedCfg.Agents.Defaults.ModelName)
|
||||
assert.Empty(t, updatedCfg.Agents.Defaults.Model)
|
||||
}
|
||||
|
||||
func TestSetDefaultModel_LegacyModelField(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Model: "legacy-old",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
|
||||
},
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
err := setDefaultModel(configPath, cfg, "new-model")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "Default model changed from 'legacy-old' to 'new-model'")
|
||||
}
|
||||
|
||||
func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "existing-model",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "existing-model", Model: "openai/existing", APIKey: "test"},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model"))
|
||||
}
|
||||
|
||||
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "existing-model",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "existing-model", Model: "openai/existing", APIKey: "test"},
|
||||
{ModelName: "no-key-model", Model: "openai/nokey", APIKey: ""},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model"))
|
||||
}
|
||||
|
||||
func TestSetDefaultModel_SaveConfigError(t *testing.T) {
|
||||
// Use an invalid path to trigger save error
|
||||
invalidPath := "/nonexistent/directory/config.json"
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "old-model",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
|
||||
},
|
||||
}
|
||||
|
||||
err := setDefaultModel(invalidPath, cfg, "new-model")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to save config")
|
||||
}
|
||||
|
||||
func TestFormatModelName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"empty string", "", "(none)"},
|
||||
{"simple model", "gpt-4", "gpt-4"},
|
||||
{"model with version", "claude-sonnet-4.6", "claude-sonnet-4.6"},
|
||||
{"model with spaces", "my model", "my model"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := formatModelName(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelCommandExecution_Show(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
// Create a test config
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "test-model",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "test-model", Model: "openai/test", APIKey: "test"},
|
||||
},
|
||||
}
|
||||
|
||||
err := config.SaveConfig(configPath, cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd := NewModelCommand()
|
||||
|
||||
output := captureStdout(func() {
|
||||
err = cmd.RunE(cmd, []string{})
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "Current default model: test-model")
|
||||
}
|
||||
|
||||
func TestModelCommandExecution_Set(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "old-model",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "old-model", Model: "openai/old", APIKey: "test"},
|
||||
{ModelName: "new-model", Model: "openai/new", APIKey: "test"},
|
||||
},
|
||||
}
|
||||
|
||||
err := config.SaveConfig(configPath, cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd := NewModelCommand()
|
||||
|
||||
output := captureStdout(func() {
|
||||
err = cmd.RunE(cmd, []string{"new-model"})
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'")
|
||||
}
|
||||
|
||||
func TestModelCommandExecution_TooManyArgs(t *testing.T) {
|
||||
cmd := NewModelCommand()
|
||||
|
||||
err := cmd.RunE(cmd, []string{"model1", "model2"})
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestListAvailableModels_MarkerLogic(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "middle-model",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "first-model", Model: "openai/first", APIKey: "test"},
|
||||
{ModelName: "middle-model", Model: "openai/middle", APIKey: "test"},
|
||||
{ModelName: "last-model", Model: "openai/last", APIKey: "test"},
|
||||
},
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
listAvailableModels(cfg)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, " - first-model (openai/first)")
|
||||
assert.Contains(t, output, "> - middle-model (openai/middle)")
|
||||
assert.Contains(t, output, " - last-model (openai/last)")
|
||||
}
|
||||
|
|
@ -29,7 +29,15 @@ func NewSkillsCommand() *cobra.Command {
|
|||
}
|
||||
|
||||
d.workspace = cfg.WorkspacePath()
|
||||
d.installer = skills.NewSkillInstaller(d.workspace)
|
||||
installer, err := skills.NewSkillInstaller(
|
||||
d.workspace,
|
||||
cfg.Tools.Skills.Github.Token,
|
||||
cfg.Tools.Skills.Github.Proxy,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating skills installer: %w", err)
|
||||
}
|
||||
d.installer = installer
|
||||
|
||||
// get global config directory and builtin skills directory
|
||||
globalDir := filepath.Dir(internal.GetConfigPath())
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/model"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
||||
|
|
@ -43,6 +44,7 @@ func NewPicoclawCommand() *cobra.Command {
|
|||
cron.NewCronCommand(),
|
||||
migrate.NewMigrateCommand(),
|
||||
skills.NewSkillsCommand(),
|
||||
model.NewModelCommand(),
|
||||
version.NewVersionCommand(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
|||
"cron",
|
||||
"gateway",
|
||||
"migrate",
|
||||
"model",
|
||||
"onboard",
|
||||
"skills",
|
||||
"status",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@
|
|||
"api_base": "https://api.anthropic.com/v1",
|
||||
"thinking_level": "high"
|
||||
},
|
||||
{
|
||||
"_comment": "Anthropic Messages API - use native format for direct Anthropic API access",
|
||||
"model_name": "claude-opus-4-6",
|
||||
"model": "anthropic-messages/claude-opus-4-6",
|
||||
"api_key": "sk-ant-your-key",
|
||||
"api_base": "https://api.anthropic.com"
|
||||
},
|
||||
{
|
||||
"model_name": "gemini",
|
||||
"model": "antigravity/gemini-2.0-flash",
|
||||
|
|
@ -40,6 +47,18 @@
|
|||
"model": "longcat/LongCat-Flash-Thinking",
|
||||
"api_key": "your-longcat-api-key"
|
||||
},
|
||||
{
|
||||
"model_name": "modelscope-qwen",
|
||||
"model": "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
"api_key": "your-modelscope-access-token",
|
||||
"api_base": "https://api-inference.modelscope.cn/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "azure-gpt5",
|
||||
"model": "azure/my-gpt5-deployment",
|
||||
"api_key": "your-azure-api-key",
|
||||
"api_base": "https://your-resource.openai.azure.com"
|
||||
},
|
||||
{
|
||||
"model_name": "loadbalanced-gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
|
|
@ -283,6 +302,10 @@
|
|||
"longcat": {
|
||||
"api_key": "",
|
||||
"api_base": "https://api.longcat.chat/openai"
|
||||
},
|
||||
"modelscope": {
|
||||
"api_key": "",
|
||||
"api_base": "https://api-inference.modelscope.cn/v1"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
|
|
@ -427,6 +450,10 @@
|
|||
"max_response_size": 0
|
||||
}
|
||||
},
|
||||
"github": {
|
||||
"proxy": "http://127.0.0.1:7891",
|
||||
"token": ""
|
||||
},
|
||||
"max_concurrent_searches": 2,
|
||||
"search_cache": {
|
||||
"max_size": 50,
|
||||
|
|
|
|||
18
go.mod
18
go.mod
|
|
@ -4,20 +4,20 @@ go 1.25.7
|
|||
|
||||
require (
|
||||
github.com/adhocore/gronx v1.19.6
|
||||
github.com/anthropics/anthropic-sdk-go v1.22.1
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/caarlos0/env/v11 v11.3.1
|
||||
github.com/chzyer/readline v1.5.1
|
||||
github.com/caarlos0/env/v11 v11.4.0
|
||||
github.com/ergochat/irc-go v0.5.0
|
||||
github.com/ergochat/readline v0.1.3
|
||||
github.com/gdamore/tcell/v2 v2.13.8
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/h2non/filetype v1.1.3
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
github.com/modelcontextprotocol/go-sdk v1.3.1
|
||||
github.com/mymmrac/telego v1.6.0
|
||||
github.com/mymmrac/telego v1.7.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/openai/openai-go/v3 v3.22.0
|
||||
github.com/rivo/tview v0.42.0
|
||||
|
|
@ -27,9 +27,10 @@ require (
|
|||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tencent-connect/botgo v0.2.1
|
||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
maunium.net/go/mautrix v0.26.3
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
|
@ -60,7 +61,6 @@ require (
|
|||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
|
@ -73,7 +73,7 @@ require (
|
|||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/github/copilot-sdk/go v0.1.23
|
||||
github.com/github/copilot-sdk/go v0.1.32
|
||||
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
|
|
@ -87,7 +87,7 @@ require (
|
|||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.7 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
|
|
|
|||
38
go.sum
38
go.sum
|
|
@ -11,8 +11,8 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg
|
|||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
|
||||
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
||||
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||
|
|
@ -23,16 +23,10 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS
|
|||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
||||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
|
||||
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
||||
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
||||
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
|
||||
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
|
|
@ -44,20 +38,24 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||
github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw=
|
||||
github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
|
||||
github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4pSo=
|
||||
github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
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/github/copilot-sdk/go v0.1.32 h1:wc9SFWwxXhJts6vyzzboPLJqcEJGnHE8rMCAY1RrUgo=
|
||||
github.com/github/copilot-sdk/go v0.1.32/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts=
|
||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
||||
|
|
@ -140,8 +138,8 @@ github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFe
|
|||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||
github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
|
||||
github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw=
|
||||
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
||||
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
||||
github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=
|
||||
github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
|
|
@ -220,8 +218,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
|
|||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
||||
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
|
|
@ -271,13 +269,11 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
|||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
|
@ -299,7 +295,6 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
|
@ -361,6 +356,7 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
|
|||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ type AgentLoop struct {
|
|||
transcriber voice.Transcriber
|
||||
cmdRegistry *commands.Registry
|
||||
mcp mcpRuntime
|
||||
mu sync.RWMutex
|
||||
// Track active requests for safe provider cleanup
|
||||
activeRequests sync.WaitGroup
|
||||
}
|
||||
|
||||
// processOptions configures how a message is processed
|
||||
|
|
@ -239,6 +242,7 @@ func registerSharedTools(
|
|||
|
||||
func (al *AgentLoop) Run(ctx context.Context) error {
|
||||
al.running.Store(true)
|
||||
|
||||
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -278,7 +282,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
|||
// If so, skip publishing to avoid duplicate messages to the user.
|
||||
// Use default agent's tools to check (message tool is shared).
|
||||
alreadySent := false
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
defaultAgent := al.GetRegistry().GetDefaultAgent()
|
||||
if defaultAgent != nil {
|
||||
if tool, ok := defaultAgent.Tools.Get("message"); ok {
|
||||
if mt, ok := tool.(*tools.MessageTool); ok {
|
||||
|
|
@ -331,12 +335,13 @@ func (al *AgentLoop) Close() {
|
|||
}
|
||||
}
|
||||
|
||||
al.registry.Close()
|
||||
al.GetRegistry().Close()
|
||||
}
|
||||
|
||||
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
|
||||
for _, agentID := range al.registry.ListAgentIDs() {
|
||||
if agent, ok := al.registry.GetAgent(agentID); ok {
|
||||
registry := al.GetRegistry()
|
||||
for _, agentID := range registry.ListAgentIDs() {
|
||||
if agent, ok := registry.GetAgent(agentID); ok {
|
||||
agent.Tools.Register(tool)
|
||||
}
|
||||
}
|
||||
|
|
@ -346,12 +351,123 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
|||
al.channelManager = cm
|
||||
}
|
||||
|
||||
// ReloadProviderAndConfig atomically swaps the provider and config with proper synchronization.
|
||||
// It uses a context to allow timeout control from the caller.
|
||||
// Returns an error if the reload fails or context is canceled.
|
||||
func (al *AgentLoop) ReloadProviderAndConfig(
|
||||
ctx context.Context,
|
||||
provider providers.LLMProvider,
|
||||
cfg *config.Config,
|
||||
) error {
|
||||
// Validate inputs
|
||||
if provider == nil {
|
||||
return fmt.Errorf("provider cannot be nil")
|
||||
}
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config cannot be nil")
|
||||
}
|
||||
|
||||
// Create new registry with updated config and provider
|
||||
// Wrap in defer/recover to handle any panics gracefully
|
||||
var registry *AgentRegistry
|
||||
var panicErr error
|
||||
done := make(chan struct{}, 1)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
panicErr = fmt.Errorf("panic during registry creation: %v", r)
|
||||
logger.ErrorCF("agent", "Panic during registry creation",
|
||||
map[string]any{"panic": r})
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
registry = NewAgentRegistry(cfg, provider)
|
||||
}()
|
||||
|
||||
// Wait for completion or context cancellation
|
||||
select {
|
||||
case <-done:
|
||||
if registry == nil {
|
||||
if panicErr != nil {
|
||||
return fmt.Errorf("registry creation failed: %w", panicErr)
|
||||
}
|
||||
return fmt.Errorf("registry creation failed (nil result)")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("context canceled during registry creation: %w", ctx.Err())
|
||||
}
|
||||
|
||||
// Check context again before proceeding
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("context canceled after registry creation: %w", err)
|
||||
}
|
||||
|
||||
// Ensure shared tools are re-registered on the new registry
|
||||
registerSharedTools(cfg, al.bus, registry, provider)
|
||||
|
||||
// Atomically swap the config and registry under write lock
|
||||
// This ensures readers see a consistent pair
|
||||
al.mu.Lock()
|
||||
oldRegistry := al.registry
|
||||
|
||||
// Store new values
|
||||
al.cfg = cfg
|
||||
al.registry = registry
|
||||
|
||||
// Also update fallback chain with new config
|
||||
al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker())
|
||||
|
||||
al.mu.Unlock()
|
||||
|
||||
// Close old provider after releasing the lock
|
||||
// This prevents blocking readers while closing
|
||||
if oldProvider, ok := extractProvider(oldRegistry); ok {
|
||||
if stateful, ok := oldProvider.(providers.StatefulProvider); ok {
|
||||
// Give in-flight requests a moment to complete
|
||||
// Use a reasonable timeout that balances cleanup vs resource usage
|
||||
select {
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
stateful.Close()
|
||||
case <-ctx.Done():
|
||||
// Context canceled, close immediately but log warning
|
||||
logger.WarnCF("agent", "Context canceled during provider cleanup, forcing close",
|
||||
map[string]any{"error": ctx.Err()})
|
||||
stateful.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.InfoCF("agent", "Provider and config reloaded successfully",
|
||||
map[string]any{
|
||||
"model": cfg.Agents.Defaults.GetModelName(),
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRegistry returns the current registry (thread-safe)
|
||||
func (al *AgentLoop) GetRegistry() *AgentRegistry {
|
||||
al.mu.RLock()
|
||||
defer al.mu.RUnlock()
|
||||
return al.registry
|
||||
}
|
||||
|
||||
// GetConfig returns the current config (thread-safe)
|
||||
func (al *AgentLoop) GetConfig() *config.Config {
|
||||
al.mu.RLock()
|
||||
defer al.mu.RUnlock()
|
||||
return al.cfg
|
||||
}
|
||||
|
||||
// SetMediaStore injects a MediaStore for media lifecycle management.
|
||||
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
||||
al.mediaStore = s
|
||||
|
||||
// Propagate store to send_file tools in all agents.
|
||||
al.registry.ForEachTool("send_file", func(t tools.Tool) {
|
||||
registry := al.GetRegistry()
|
||||
registry.ForEachTool("send_file", func(t tools.Tool) {
|
||||
if sf, ok := t.(*tools.SendFileTool); ok {
|
||||
sf.SetMediaStore(s)
|
||||
}
|
||||
|
|
@ -540,7 +656,7 @@ func (al *AgentLoop) ProcessHeartbeat(
|
|||
ctx context.Context,
|
||||
content, channel, chatID string,
|
||||
) (string, error) {
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
agent := al.GetRegistry().GetDefaultAgent()
|
||||
if agent == nil {
|
||||
return "", fmt.Errorf("no default agent for heartbeat")
|
||||
}
|
||||
|
|
@ -636,7 +752,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||
}
|
||||
|
||||
func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) {
|
||||
route := al.registry.ResolveRoute(routing.RouteInput{
|
||||
registry := al.GetRegistry()
|
||||
route := registry.ResolveRoute(routing.RouteInput{
|
||||
Channel: msg.Channel,
|
||||
AccountID: inboundMetadata(msg, metadataKeyAccountID),
|
||||
Peer: extractPeer(msg),
|
||||
|
|
@ -645,9 +762,9 @@ func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.Resolv
|
|||
TeamID: inboundMetadata(msg, metadataKeyTeamID),
|
||||
})
|
||||
|
||||
agent, ok := al.registry.GetAgent(route.AgentID)
|
||||
agent, ok := registry.GetAgent(route.AgentID)
|
||||
if !ok {
|
||||
agent = al.registry.GetDefaultAgent()
|
||||
agent = registry.GetDefaultAgent()
|
||||
}
|
||||
if agent == nil {
|
||||
return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
|
||||
|
|
@ -709,7 +826,7 @@ func (al *AgentLoop) processSystemMessage(
|
|||
}
|
||||
|
||||
// Use default agent for system messages
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
agent := al.GetRegistry().GetDefaultAgent()
|
||||
if agent == nil {
|
||||
return "", fmt.Errorf("no default agent for system message")
|
||||
}
|
||||
|
|
@ -764,8 +881,9 @@ func (al *AgentLoop) runAgentLoop(
|
|||
opts.ChatID,
|
||||
)
|
||||
|
||||
// Resolve media:// refs to base64 data URLs (streaming)
|
||||
maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize()
|
||||
// Resolve media:// refs: images→base64 data URLs, non-images→local paths in content
|
||||
cfg := al.GetConfig()
|
||||
maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize()
|
||||
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
||||
|
||||
// 2. Save user message to session
|
||||
|
|
@ -943,6 +1061,9 @@ func (al *AgentLoop) runLLMIteration(
|
|||
}
|
||||
|
||||
callLLM := func() (*providers.LLMResponse, error) {
|
||||
al.activeRequests.Add(1)
|
||||
defer al.activeRequests.Done()
|
||||
|
||||
if len(activeCandidates) > 1 && al.fallback != nil {
|
||||
fbResult, fbErr := al.fallback.Execute(
|
||||
ctx,
|
||||
|
|
@ -1041,6 +1162,7 @@ func (al *AgentLoop) runLLMIteration(
|
|||
map[string]any{
|
||||
"agent_id": agent.ID,
|
||||
"iteration": iteration,
|
||||
"model": activeModel,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
|
||||
|
|
@ -1392,7 +1514,8 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
|
|||
func (al *AgentLoop) GetStartupInfo() map[string]any {
|
||||
info := make(map[string]any)
|
||||
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
registry := al.GetRegistry()
|
||||
agent := registry.GetDefaultAgent()
|
||||
if agent == nil {
|
||||
return info
|
||||
}
|
||||
|
|
@ -1409,8 +1532,8 @@ func (al *AgentLoop) GetStartupInfo() map[string]any {
|
|||
|
||||
// Agents info
|
||||
info["agents"] = map[string]any{
|
||||
"count": len(al.registry.ListAgentIDs()),
|
||||
"ids": al.registry.ListAgentIDs(),
|
||||
"count": len(registry.ListAgentIDs()),
|
||||
"ids": registry.ListAgentIDs(),
|
||||
}
|
||||
|
||||
return info
|
||||
|
|
@ -1598,7 +1721,10 @@ func (al *AgentLoop) retryLLMCall(
|
|||
var err error
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
resp, err = agent.Provider.Chat(
|
||||
al.activeRequests.Add(1)
|
||||
resp, err = func() (*providers.LLMResponse, error) {
|
||||
defer al.activeRequests.Done()
|
||||
return agent.Provider.Chat(
|
||||
ctx,
|
||||
[]providers.Message{{Role: "user", Content: prompt}},
|
||||
nil,
|
||||
|
|
@ -1609,6 +1735,8 @@ func (al *AgentLoop) retryLLMCall(
|
|||
"prompt_cache_key": agent.ID,
|
||||
},
|
||||
)
|
||||
}()
|
||||
|
||||
if err == nil && resp != nil && resp.Content != "" {
|
||||
return resp, nil
|
||||
}
|
||||
|
|
@ -1741,9 +1869,11 @@ func (al *AgentLoop) handleCommand(
|
|||
}
|
||||
|
||||
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime {
|
||||
registry := al.GetRegistry()
|
||||
cfg := al.GetConfig()
|
||||
rt := &commands.Runtime{
|
||||
Config: al.cfg,
|
||||
ListAgentIDs: al.registry.ListAgentIDs,
|
||||
Config: cfg,
|
||||
ListAgentIDs: registry.ListAgentIDs,
|
||||
ListDefinitions: al.cmdRegistry.Definitions,
|
||||
GetEnabledChannels: func() []string {
|
||||
if al.channelManager == nil {
|
||||
|
|
@ -1763,7 +1893,7 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
|
|||
}
|
||||
if agent != nil {
|
||||
rt.GetModelInfo = func() (string, string) {
|
||||
return agent.Model, al.cfg.Agents.Defaults.Provider
|
||||
return agent.Model, cfg.Agents.Defaults.Provider
|
||||
}
|
||||
rt.SwitchModel = func(value string) (string, error) {
|
||||
oldModel := agent.Model
|
||||
|
|
@ -1827,3 +1957,16 @@ func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
|||
}
|
||||
return &routing.RoutePeer{Kind: parentKind, ID: parentID}
|
||||
}
|
||||
|
||||
// Helper to extract provider from registry for cleanup
|
||||
func extractProvider(registry *AgentRegistry) (providers.LLMProvider, bool) {
|
||||
if registry == nil {
|
||||
return nil, false
|
||||
}
|
||||
// Get any agent to access the provider
|
||||
defaultAgent := registry.GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
return nil, false
|
||||
}
|
||||
return defaultAgent.Provider, true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,22 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
if al.cfg.Tools.MCP.Servers == nil || len(al.cfg.Tools.MCP.Servers) == 0 {
|
||||
logger.WarnCF("agent", "MCP is enabled but no servers are configured, skipping MCP initialization", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
findValidServer := false
|
||||
for _, serverCfg := range al.cfg.Tools.MCP.Servers {
|
||||
if serverCfg.Enabled {
|
||||
findValidServer = true
|
||||
}
|
||||
}
|
||||
if !findValidServer {
|
||||
logger.WarnCF("agent", "MCP is enabled but no valid servers are configured, skipping MCP initialization", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
al.mcp.initOnce.Do(func() {
|
||||
mcpManager := mcp.NewManager()
|
||||
|
||||
|
|
|
|||
|
|
@ -20,9 +20,10 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// resolveMediaRefs replaces media:// refs in message Media fields with base64 data URLs.
|
||||
// Uses streaming base64 encoding (file handle → encoder → buffer) to avoid holding
|
||||
// both raw bytes and encoded string in memory simultaneously.
|
||||
// resolveMediaRefs resolves media:// refs in messages.
|
||||
// Images are base64-encoded into the Media array for multimodal LLMs.
|
||||
// Non-image files (documents, audio, video) have their local path injected
|
||||
// into Content so the agent can access them via file tools like read_file.
|
||||
// Returns a new slice; original messages are not mutated.
|
||||
func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message {
|
||||
if store == nil {
|
||||
|
|
@ -38,6 +39,8 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
|
|||
}
|
||||
|
||||
resolved := make([]string, 0, len(m.Media))
|
||||
var pathTags []string
|
||||
|
||||
for _, ref := range m.Media {
|
||||
if !strings.HasPrefix(ref, "media://") {
|
||||
resolved = append(resolved, ref)
|
||||
|
|
@ -61,38 +64,63 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
|
|||
})
|
||||
continue
|
||||
}
|
||||
|
||||
mime := detectMIME(localPath, meta)
|
||||
|
||||
if strings.HasPrefix(mime, "image/") {
|
||||
dataURL := encodeImageToDataURL(localPath, mime, info, maxSize)
|
||||
if dataURL != "" {
|
||||
resolved = append(resolved, dataURL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
pathTags = append(pathTags, buildPathTag(mime, localPath))
|
||||
}
|
||||
|
||||
result[i].Media = resolved
|
||||
if len(pathTags) > 0 {
|
||||
result[i].Content = injectPathTags(result[i].Content, pathTags)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// detectMIME determines the MIME type from metadata or magic-bytes detection.
|
||||
// Returns empty string if detection fails.
|
||||
func detectMIME(localPath string, meta media.MediaMeta) string {
|
||||
if meta.ContentType != "" {
|
||||
return meta.ContentType
|
||||
}
|
||||
kind, err := filetype.MatchFile(localPath)
|
||||
if err != nil || kind == filetype.Unknown {
|
||||
return ""
|
||||
}
|
||||
return kind.MIME.Value
|
||||
}
|
||||
|
||||
// encodeImageToDataURL base64-encodes an image file into a data URL.
|
||||
// Returns empty string if the file exceeds maxSize or encoding fails.
|
||||
func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int) string {
|
||||
if info.Size() > int64(maxSize) {
|
||||
logger.WarnCF("agent", "Media file too large, skipping", map[string]any{
|
||||
"path": localPath,
|
||||
"size": info.Size(),
|
||||
"max_size": maxSize,
|
||||
})
|
||||
continue
|
||||
return ""
|
||||
}
|
||||
|
||||
// Determine MIME type: prefer metadata, fallback to magic-bytes detection
|
||||
mime := meta.ContentType
|
||||
if mime == "" {
|
||||
kind, ftErr := filetype.MatchFile(localPath)
|
||||
if ftErr != nil || kind == filetype.Unknown {
|
||||
logger.WarnCF("agent", "Unknown media type, skipping", map[string]any{
|
||||
"path": localPath,
|
||||
})
|
||||
continue
|
||||
}
|
||||
mime = kind.MIME.Value
|
||||
}
|
||||
|
||||
// Streaming base64: open file → base64 encoder → buffer
|
||||
// Peak memory: ~1.33x file size (buffer only, no raw bytes copy)
|
||||
f, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
logger.WarnCF("agent", "Failed to open media file", map[string]any{
|
||||
"path": localPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
prefix := "data:" + mime + ";base64,"
|
||||
encodedLen := base64.StdEncoding.EncodedLen(int(info.Size()))
|
||||
|
|
@ -102,21 +130,51 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
|
|||
|
||||
encoder := base64.NewEncoder(base64.StdEncoding, &buf)
|
||||
if _, err := io.Copy(encoder, f); err != nil {
|
||||
f.Close()
|
||||
logger.WarnCF("agent", "Failed to encode media file", map[string]any{
|
||||
"path": localPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
return ""
|
||||
}
|
||||
encoder.Close()
|
||||
f.Close()
|
||||
|
||||
resolved = append(resolved, buf.String())
|
||||
}
|
||||
|
||||
result[i].Media = resolved
|
||||
}
|
||||
|
||||
return result
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// buildPathTag creates a structured tag exposing the local file path.
|
||||
// Tag type is derived from MIME: [audio:/path], [video:/path], or [file:/path].
|
||||
func buildPathTag(mime, localPath string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(mime, "audio/"):
|
||||
return "[audio:" + localPath + "]"
|
||||
case strings.HasPrefix(mime, "video/"):
|
||||
return "[video:" + localPath + "]"
|
||||
default:
|
||||
return "[file:" + localPath + "]"
|
||||
}
|
||||
}
|
||||
|
||||
// injectPathTags replaces generic media tags in content with path-bearing versions,
|
||||
// or appends if no matching generic tag is found.
|
||||
func injectPathTags(content string, tags []string) string {
|
||||
for _, tag := range tags {
|
||||
var generic string
|
||||
switch {
|
||||
case strings.HasPrefix(tag, "[audio:"):
|
||||
generic = "[audio]"
|
||||
case strings.HasPrefix(tag, "[video:"):
|
||||
generic = "[video]"
|
||||
case strings.HasPrefix(tag, "[file:"):
|
||||
generic = "[file]"
|
||||
}
|
||||
|
||||
if generic != "" && strings.Contains(content, generic) {
|
||||
content = strings.Replace(content, generic, tag, 1)
|
||||
} else if content == "" {
|
||||
content = tag
|
||||
} else {
|
||||
content += " " + tag
|
||||
}
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
|
|
|||
|
|
@ -770,13 +770,18 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProcessDirectWithChannel_InitializesMCPInAgentMode(t *testing.T) {
|
||||
// TestProcessDirectWithChannel_TriggersMCPInitialization verifies that
|
||||
// ProcessDirectWithChannel triggers MCP initialization when MCP is enabled.
|
||||
// Note: Manager is only initialized when at least one MCP server is configured
|
||||
// and successfully connected.
|
||||
func TestProcessDirectWithChannel_TriggersMCPInitialization(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Test with MCP enabled but no servers - should not initialize manager
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
|
|
@ -791,6 +796,7 @@ func TestProcessDirectWithChannel_InitializesMCPInAgentMode(t *testing.T) {
|
|||
ToolConfig: config.ToolConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
// No servers configured - manager should not be initialized
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -815,8 +821,9 @@ func TestProcessDirectWithChannel_InitializesMCPInAgentMode(t *testing.T) {
|
|||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
|
||||
if !al.mcp.hasManager() {
|
||||
t.Fatal("expected MCP manager to be initialized in direct agent mode")
|
||||
// Manager should not be initialized when no servers are configured
|
||||
if al.mcp.hasManager() {
|
||||
t.Fatal("expected MCP manager to be nil when no servers are configured")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1088,7 +1095,7 @@ func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaRefs_SkipsUnknownType(t *testing.T) {
|
||||
func TestResolveMediaRefs_UnknownTypeInjectsPath(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
dir := t.TempDir()
|
||||
|
||||
|
|
@ -1104,7 +1111,11 @@ func TestResolveMediaRefs_SkipsUnknownType(t *testing.T) {
|
|||
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||
|
||||
if len(result[0].Media) != 0 {
|
||||
t.Fatalf("expected 0 media (unknown type), got %d", len(result[0].Media))
|
||||
t.Fatalf("expected 0 media entries, got %d", len(result[0].Media))
|
||||
}
|
||||
expected := "hi [file:" + txtPath + "]"
|
||||
if result[0].Content != expected {
|
||||
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1166,3 +1177,144 @@ func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) {
|
|||
t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaRefs_PDFInjectsFilePath(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
dir := t.TempDir()
|
||||
|
||||
pdfPath := filepath.Join(dir, "report.pdf")
|
||||
// PDF magic bytes
|
||||
os.WriteFile(pdfPath, []byte("%PDF-1.4 test content"), 0o644)
|
||||
ref, _ := store.Store(pdfPath, media.MediaMeta{ContentType: "application/pdf"}, "test")
|
||||
|
||||
messages := []providers.Message{
|
||||
{Role: "user", Content: "report.pdf [file]", Media: []string{ref}},
|
||||
}
|
||||
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||
|
||||
if len(result[0].Media) != 0 {
|
||||
t.Fatalf("expected 0 media (non-image), got %d", len(result[0].Media))
|
||||
}
|
||||
expected := "report.pdf [file:" + pdfPath + "]"
|
||||
if result[0].Content != expected {
|
||||
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaRefs_AudioInjectsAudioPath(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
dir := t.TempDir()
|
||||
|
||||
oggPath := filepath.Join(dir, "voice.ogg")
|
||||
os.WriteFile(oggPath, []byte("fake audio"), 0o644)
|
||||
ref, _ := store.Store(oggPath, media.MediaMeta{ContentType: "audio/ogg"}, "test")
|
||||
|
||||
messages := []providers.Message{
|
||||
{Role: "user", Content: "voice.ogg [audio]", Media: []string{ref}},
|
||||
}
|
||||
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||
|
||||
if len(result[0].Media) != 0 {
|
||||
t.Fatalf("expected 0 media, got %d", len(result[0].Media))
|
||||
}
|
||||
expected := "voice.ogg [audio:" + oggPath + "]"
|
||||
if result[0].Content != expected {
|
||||
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaRefs_VideoInjectsVideoPath(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
dir := t.TempDir()
|
||||
|
||||
mp4Path := filepath.Join(dir, "clip.mp4")
|
||||
os.WriteFile(mp4Path, []byte("fake video"), 0o644)
|
||||
ref, _ := store.Store(mp4Path, media.MediaMeta{ContentType: "video/mp4"}, "test")
|
||||
|
||||
messages := []providers.Message{
|
||||
{Role: "user", Content: "clip.mp4 [video]", Media: []string{ref}},
|
||||
}
|
||||
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||
|
||||
if len(result[0].Media) != 0 {
|
||||
t.Fatalf("expected 0 media, got %d", len(result[0].Media))
|
||||
}
|
||||
expected := "clip.mp4 [video:" + mp4Path + "]"
|
||||
if result[0].Content != expected {
|
||||
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaRefs_NoGenericTagAppendsPath(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
dir := t.TempDir()
|
||||
|
||||
csvPath := filepath.Join(dir, "data.csv")
|
||||
os.WriteFile(csvPath, []byte("a,b,c"), 0o644)
|
||||
ref, _ := store.Store(csvPath, media.MediaMeta{ContentType: "text/csv"}, "test")
|
||||
|
||||
messages := []providers.Message{
|
||||
{Role: "user", Content: "here is my data", Media: []string{ref}},
|
||||
}
|
||||
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||
|
||||
expected := "here is my data [file:" + csvPath + "]"
|
||||
if result[0].Content != expected {
|
||||
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaRefs_EmptyContentGetsPathTag(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
dir := t.TempDir()
|
||||
|
||||
docPath := filepath.Join(dir, "doc.docx")
|
||||
os.WriteFile(docPath, []byte("fake docx"), 0o644)
|
||||
docxMIME := "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
ref, _ := store.Store(docPath, media.MediaMeta{ContentType: docxMIME}, "test")
|
||||
|
||||
messages := []providers.Message{
|
||||
{Role: "user", Content: "", Media: []string{ref}},
|
||||
}
|
||||
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||
|
||||
expected := "[file:" + docPath + "]"
|
||||
if result[0].Content != expected {
|
||||
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaRefs_MixedImageAndFile(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
dir := t.TempDir()
|
||||
|
||||
pngPath := filepath.Join(dir, "photo.png")
|
||||
pngHeader := []byte{
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02,
|
||||
0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE,
|
||||
}
|
||||
os.WriteFile(pngPath, pngHeader, 0o644)
|
||||
imgRef, _ := store.Store(pngPath, media.MediaMeta{}, "test")
|
||||
|
||||
pdfPath := filepath.Join(dir, "report.pdf")
|
||||
os.WriteFile(pdfPath, []byte("%PDF-1.4 test"), 0o644)
|
||||
fileRef, _ := store.Store(pdfPath, media.MediaMeta{ContentType: "application/pdf"}, "test")
|
||||
|
||||
messages := []providers.Message{
|
||||
{Role: "user", Content: "check these [file]", Media: []string{imgRef, fileRef}},
|
||||
}
|
||||
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||
|
||||
if len(result[0].Media) != 1 {
|
||||
t.Fatalf("expected 1 media (image only), got %d", len(result[0].Media))
|
||||
}
|
||||
if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") {
|
||||
t.Fatal("expected image to be base64 encoded")
|
||||
}
|
||||
expectedContent := "check these [file:" + pdfPath + "]"
|
||||
if result[0].Content != expectedContent {
|
||||
t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -251,7 +251,13 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
conn, err := c.upgrader.Upgrade(w, r, nil)
|
||||
// Echo the matched subprotocol back so the browser accepts the upgrade.
|
||||
var responseHeader http.Header
|
||||
if proto := c.matchedSubprotocol(r); proto != "" {
|
||||
responseHeader = http.Header{"Sec-WebSocket-Protocol": {proto}}
|
||||
}
|
||||
|
||||
conn, err := c.upgrader.Upgrade(w, r, responseHeader)
|
||||
if err != nil {
|
||||
logger.ErrorCF("pico", "WebSocket upgrade failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
|
|
@ -282,8 +288,10 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||
go c.readLoop(pc)
|
||||
}
|
||||
|
||||
// authenticate checks the Bearer token from the Authorization header.
|
||||
// Query parameter authentication is only allowed when AllowTokenQuery is explicitly enabled.
|
||||
// authenticate checks the request for a valid token:
|
||||
// 1. Authorization: Bearer <token> header
|
||||
// 2. Sec-WebSocket-Protocol "token.<value>" (for browsers that can't set headers)
|
||||
// 3. Query parameter "token" (only when AllowTokenQuery is on)
|
||||
func (c *PicoChannel) authenticate(r *http.Request) bool {
|
||||
token := c.config.Token
|
||||
if token == "" {
|
||||
|
|
@ -298,6 +306,11 @@ func (c *PicoChannel) authenticate(r *http.Request) bool {
|
|||
}
|
||||
}
|
||||
|
||||
// Check Sec-WebSocket-Protocol subprotocol ("token.<value>")
|
||||
if c.matchedSubprotocol(r) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check query parameter only when explicitly allowed
|
||||
if c.config.AllowTokenQuery {
|
||||
if r.URL.Query().Get("token") == token {
|
||||
|
|
@ -308,6 +321,18 @@ func (c *PicoChannel) authenticate(r *http.Request) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// matchedSubprotocol returns the "token.<value>" subprotocol that matches
|
||||
// the configured token, or "" if none do.
|
||||
func (c *PicoChannel) matchedSubprotocol(r *http.Request) string {
|
||||
token := c.config.Token
|
||||
for _, proto := range websocket.Subprotocols(r) {
|
||||
if after, ok := strings.CutPrefix(proto, "token."); ok && after == token {
|
||||
return proto
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// readLoop reads messages from a WebSocket connection.
|
||||
func (c *PicoChannel) readLoop(pc *picoConn) {
|
||||
defer func() {
|
||||
|
|
|
|||
|
|
@ -423,7 +423,9 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
|||
// Reset msg_seq counter for new inbound message.
|
||||
c.msgSeqCounters.Store(senderID, new(atomic.Uint64))
|
||||
|
||||
metadata := map[string]string{}
|
||||
metadata := map[string]string{
|
||||
"account_id": senderID,
|
||||
}
|
||||
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "qq",
|
||||
|
|
@ -495,6 +497,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
|||
c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64))
|
||||
|
||||
metadata := map[string]string{
|
||||
"account_id": senderID,
|
||||
"group_id": data.GroupID,
|
||||
}
|
||||
|
||||
|
|
|
|||
44
pkg/channels/qq/qq_test.go
Normal file
44
pkg/channels/qq/qq_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package qq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tencent-connect/botgo/dto"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
)
|
||||
|
||||
func TestHandleC2CMessage_IncludesAccountIDMetadata(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch := &QQChannel{
|
||||
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
||||
dedup: make(map[string]time.Time),
|
||||
done: make(chan struct{}),
|
||||
ctx: context.Background(),
|
||||
}
|
||||
|
||||
err := ch.handleC2CMessage()(nil, &dto.WSC2CMessageData{
|
||||
ID: "msg-1",
|
||||
Content: "hello",
|
||||
Author: &dto.User{
|
||||
ID: "7750283E123456",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handleC2CMessage() error = %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||
if !ok {
|
||||
t.Fatal("expected inbound message")
|
||||
}
|
||||
if inbound.Metadata["account_id"] != "7750283E123456" {
|
||||
t.Fatalf("account_id metadata = %q, want %q", inbound.Metadata["account_id"], "7750283E123456")
|
||||
}
|
||||
}
|
||||
|
|
@ -222,8 +222,8 @@ type AgentDefaults struct {
|
|||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
|
||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
||||
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
||||
ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
||||
Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
||||
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
||||
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||
|
|
@ -555,6 +555,7 @@ type ProvidersConfig struct {
|
|||
Avian ProviderConfig `json:"avian"`
|
||||
Minimax ProviderConfig `json:"minimax"`
|
||||
LongCat ProviderConfig `json:"longcat"`
|
||||
ModelScope ProviderConfig `json:"modelscope"`
|
||||
}
|
||||
|
||||
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
||||
|
|
@ -582,7 +583,8 @@ func (p ProvidersConfig) IsEmpty() bool {
|
|||
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
|
||||
p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
|
||||
p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
|
||||
p.LongCat.APIKey == "" && p.LongCat.APIBase == ""
|
||||
p.LongCat.APIKey == "" && p.LongCat.APIBase == "" &&
|
||||
p.ModelScope.APIKey == "" && p.ModelScope.APIBase == ""
|
||||
}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||
|
|
@ -738,6 +740,7 @@ type ExecConfig struct {
|
|||
type SkillsToolsConfig struct {
|
||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
|
||||
Registries SkillsRegistriesConfig ` json:"registries"`
|
||||
Github SkillsGithubConfig ` json:"github"`
|
||||
MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"`
|
||||
SearchCache SearchCacheConfig ` json:"search_cache"`
|
||||
}
|
||||
|
|
@ -787,6 +790,11 @@ type SkillsRegistriesConfig struct {
|
|||
ClawHub ClawHubRegistryConfig `json:"clawhub"`
|
||||
}
|
||||
|
||||
type SkillsGithubConfig struct {
|
||||
Token string `json:"token,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_AUTH_TOKEN"`
|
||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"`
|
||||
}
|
||||
|
||||
type ClawHubRegistryConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
|
||||
BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
|
||||
|
|
|
|||
|
|
@ -342,8 +342,8 @@ func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
|
|||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(data), `"model": ""`) {
|
||||
t.Fatalf("saved config should include empty legacy model field, got: %s", string(data))
|
||||
if !strings.Contains(string(data), `"model_name": ""`) {
|
||||
t.Fatalf("saved config should include empty legacy model_name field, got: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -369,6 +369,14 @@ func DefaultConfig() *Config {
|
|||
APIKey: "",
|
||||
},
|
||||
|
||||
// ModelScope (魔搭社区) - https://modelscope.cn/my/tokens
|
||||
{
|
||||
ModelName: "modelscope-qwen",
|
||||
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
APIBase: "https://api-inference.modelscope.cn/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// VLLM (local) - http://localhost:8000
|
||||
{
|
||||
ModelName: "local-model",
|
||||
|
|
@ -376,6 +384,15 @@ func DefaultConfig() *Config {
|
|||
APIBase: "http://localhost:8000/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Azure OpenAI - https://portal.azure.com
|
||||
// model_name is a user-friendly alias; the model field's path after "azure/" is your deployment name
|
||||
{
|
||||
ModelName: "azure-gpt5",
|
||||
Model: "azure/my-gpt5-deployment",
|
||||
APIBase: "https://your-resource.openai.azure.com",
|
||||
APIKey: "",
|
||||
},
|
||||
},
|
||||
Gateway: GatewayConfig{
|
||||
Host: "127.0.0.1",
|
||||
|
|
|
|||
|
|
@ -424,6 +424,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
|||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"modelscope"},
|
||||
protocol: "modelscope",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "modelscope",
|
||||
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
APIKey: p.ModelScope.APIKey,
|
||||
APIBase: p.ModelScope.APIBase,
|
||||
Proxy: p.ModelScope.Proxy,
|
||||
RequestTimeout: p.ModelScope.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Process each provider migration
|
||||
|
|
|
|||
|
|
@ -163,14 +163,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
|||
Mistral: ProviderConfig{APIKey: "key18"},
|
||||
Avian: ProviderConfig{APIKey: "key19"},
|
||||
LongCat: ProviderConfig{APIKey: "key-longcat"},
|
||||
ModelScope: ProviderConfig{APIKey: "key-modelscope"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
// All 22 providers should be converted
|
||||
if len(result) != 22 {
|
||||
t.Errorf("len(result) = %d, want 22", len(result))
|
||||
// All 23 providers should be converted
|
||||
if len(result) != 23 {
|
||||
t.Errorf("len(result) = %d, want 23", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
|
|||
}
|
||||
}
|
||||
|
||||
// Keep track of explicit username format
|
||||
isAtUsername := strings.HasPrefix(allowed, "@")
|
||||
|
||||
// Strip leading "@" for username matching
|
||||
trimmed := strings.TrimPrefix(allowed, "@")
|
||||
|
||||
|
|
@ -75,12 +78,10 @@ func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// Match against Username
|
||||
if sender.Username != "" {
|
||||
if sender.Username == trimmed || sender.Username == allowedUser {
|
||||
// Match against Username only when explicitly requested via "@username"
|
||||
if isAtUsername && sender.Username != "" && sender.Username == trimmed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Match compound sender format against allowed parts
|
||||
if allowedUser != "" && sender.PlatformID != "" && sender.PlatformID == allowedID {
|
||||
|
|
|
|||
|
|
@ -104,6 +104,16 @@ func TestMatchAllowed(t *testing.T) {
|
|||
allowed: "@alice",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "plain entry does not match username",
|
||||
sender: bus.SenderInfo{
|
||||
Platform: "discord",
|
||||
PlatformID: "999999",
|
||||
Username: "123456",
|
||||
},
|
||||
allowed: "123456",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "@username does not match",
|
||||
sender: telegramSender,
|
||||
|
|
@ -123,6 +133,16 @@ func TestMatchAllowed(t *testing.T) {
|
|||
allowed: "999|alice",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "compound matches by ID when username differs",
|
||||
sender: bus.SenderInfo{
|
||||
Platform: "discord",
|
||||
PlatformID: "123456",
|
||||
Username: "not123456",
|
||||
},
|
||||
allowed: "123456|alice",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "compound does not match",
|
||||
sender: telegramSender,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
|
|
@ -45,6 +46,9 @@ func init() {
|
|||
consoleWriter := zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: "15:04:05", // TODO: make it configurable???
|
||||
|
||||
// Custom formatter to handle multiline strings and JSON objects
|
||||
FormatFieldValue: formatFieldValue,
|
||||
}
|
||||
|
||||
logger = zerolog.New(consoleWriter).With().Timestamp().Logger()
|
||||
|
|
@ -52,6 +56,37 @@ func init() {
|
|||
})
|
||||
}
|
||||
|
||||
func formatFieldValue(i any) string {
|
||||
var s string
|
||||
|
||||
switch val := i.(type) {
|
||||
case string:
|
||||
s = val
|
||||
case []byte:
|
||||
s = string(val)
|
||||
default:
|
||||
return fmt.Sprintf("%v", i)
|
||||
}
|
||||
|
||||
if unquoted, err := strconv.Unquote(s); err == nil {
|
||||
s = unquoted
|
||||
}
|
||||
|
||||
if strings.Contains(s, "\n") {
|
||||
return fmt.Sprintf("\n%s", s)
|
||||
}
|
||||
|
||||
if strings.Contains(s, " ") {
|
||||
if (strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}")) ||
|
||||
(strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%q", s)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func SetLevel(level LogLevel) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
|
@ -113,6 +148,7 @@ func getCallerInfo() (string, int, string) {
|
|||
|
||||
// bypass common loggers
|
||||
if strings.HasSuffix(file, "/logger.go") ||
|
||||
strings.HasSuffix(file, "/logger_3rd_party.go") ||
|
||||
strings.HasSuffix(file, "/log.go") {
|
||||
continue
|
||||
}
|
||||
|
|
@ -162,10 +198,7 @@ func logMessage(level LogLevel, component string, message string, fields map[str
|
|||
event.Str("caller", fmt.Sprintf("<none> %s:%d (%s)", callerFile, callerLine, callerFunc))
|
||||
}
|
||||
|
||||
for k, v := range fields {
|
||||
event.Interface(k, v)
|
||||
}
|
||||
|
||||
appendFields(event, fields)
|
||||
event.Msg(message)
|
||||
|
||||
// Also log to file if enabled
|
||||
|
|
@ -175,9 +208,8 @@ func logMessage(level LogLevel, component string, message string, fields map[str
|
|||
if component != "" {
|
||||
fileEvent.Str("component", component)
|
||||
}
|
||||
for k, v := range fields {
|
||||
fileEvent.Interface(k, v)
|
||||
}
|
||||
|
||||
appendFields(event, fields)
|
||||
fileEvent.Msg(message)
|
||||
}
|
||||
|
||||
|
|
@ -186,6 +218,26 @@ func logMessage(level LogLevel, component string, message string, fields map[str
|
|||
}
|
||||
}
|
||||
|
||||
func appendFields(event *zerolog.Event, fields map[string]any) {
|
||||
for k, v := range fields {
|
||||
// Type switch to avoid double JSON serialization of strings
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
event.Str(k, val)
|
||||
case int:
|
||||
event.Int(k, val)
|
||||
case int64:
|
||||
event.Int64(k, val)
|
||||
case float64:
|
||||
event.Float64(k, val)
|
||||
case bool:
|
||||
event.Bool(k, val)
|
||||
default:
|
||||
event.Interface(k, v) // Fallback for struct, slice and maps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Debug(message string) {
|
||||
logMessage(DEBUG, "", message, nil)
|
||||
}
|
||||
|
|
@ -194,6 +246,10 @@ func DebugC(component string, message string) {
|
|||
logMessage(DEBUG, component, message, nil)
|
||||
}
|
||||
|
||||
func Debugf(message string, ss ...any) {
|
||||
logMessage(DEBUG, "", fmt.Sprintf(message, ss...), nil)
|
||||
}
|
||||
|
||||
func DebugF(message string, fields map[string]any) {
|
||||
logMessage(DEBUG, "", message, fields)
|
||||
}
|
||||
|
|
@ -214,6 +270,10 @@ func InfoF(message string, fields map[string]any) {
|
|||
logMessage(INFO, "", message, fields)
|
||||
}
|
||||
|
||||
func Infof(message string, ss ...any) {
|
||||
logMessage(INFO, "", fmt.Sprintf(message, ss...), nil)
|
||||
}
|
||||
|
||||
func InfoCF(component string, message string, fields map[string]any) {
|
||||
logMessage(INFO, component, message, fields)
|
||||
}
|
||||
|
|
@ -242,6 +302,10 @@ func ErrorC(component string, message string) {
|
|||
logMessage(ERROR, component, message, nil)
|
||||
}
|
||||
|
||||
func Errorf(message string, ss ...any) {
|
||||
logMessage(ERROR, "", fmt.Sprintf(message, ss...), nil)
|
||||
}
|
||||
|
||||
func ErrorF(message string, fields map[string]any) {
|
||||
logMessage(ERROR, "", message, fields)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,17 +123,132 @@ func TestLoggerHelperFunctions(t *testing.T) {
|
|||
SetLevel(INFO)
|
||||
|
||||
Debug("This should not log")
|
||||
Debugf("this should not log")
|
||||
Info("This should log")
|
||||
Warn("This should log")
|
||||
Error("This should log")
|
||||
|
||||
InfoC("test", "Component message")
|
||||
InfoF("Fields message", map[string]any{"key": "value"})
|
||||
Infof("test from %v", "Infof")
|
||||
|
||||
WarnC("test", "Warning with component")
|
||||
ErrorF("Error with fields", map[string]any{"error": "test"})
|
||||
Errorf("test from %v", "Errorf")
|
||||
|
||||
SetLevel(DEBUG)
|
||||
DebugC("test", "Debug with component")
|
||||
Debugf("test from %v", "Debugf")
|
||||
WarnF("Warning with fields", map[string]any{"key": "value"})
|
||||
}
|
||||
|
||||
func TestFormatFieldValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input any
|
||||
expected string
|
||||
}{
|
||||
// Basic types test (default case of the switch)
|
||||
{
|
||||
name: "Integer Type",
|
||||
input: 42,
|
||||
expected: "42",
|
||||
},
|
||||
{
|
||||
name: "Boolean Type",
|
||||
input: true,
|
||||
expected: "true",
|
||||
},
|
||||
{
|
||||
name: "Unsupported Struct Type",
|
||||
input: struct{ A int }{A: 1},
|
||||
expected: "{1}",
|
||||
},
|
||||
|
||||
// Simple strings and byte slices test
|
||||
{
|
||||
name: "Simple string without spaces",
|
||||
input: "simple_value",
|
||||
expected: "simple_value",
|
||||
},
|
||||
{
|
||||
name: "Simple byte slice",
|
||||
input: []byte("byte_value"),
|
||||
expected: "byte_value",
|
||||
},
|
||||
|
||||
// Unquoting test (strconv.Unquote)
|
||||
{
|
||||
name: "Quoted string",
|
||||
input: `"quoted_value"`,
|
||||
expected: "quoted_value",
|
||||
},
|
||||
|
||||
// Strings with newline (\n) test
|
||||
{
|
||||
name: "String with newline",
|
||||
input: "line1\nline2",
|
||||
expected: "\nline1\nline2",
|
||||
},
|
||||
{
|
||||
name: "Quoted string with newline (Unquote -> newline)",
|
||||
input: `"line1\nline2"`, // Escaped \n that Unquote will resolve
|
||||
expected: "\nline1\nline2",
|
||||
},
|
||||
|
||||
// Strings with spaces test (which should be quoted)
|
||||
{
|
||||
name: "String with spaces",
|
||||
input: "hello world",
|
||||
expected: `"hello world"`,
|
||||
},
|
||||
{
|
||||
name: "Quoted string with spaces (Unquote -> has spaces -> Re-quote)",
|
||||
input: `"hello world"`,
|
||||
expected: `"hello world"`,
|
||||
},
|
||||
|
||||
// JSON formats test (strings with spaces that start/end with brackets)
|
||||
{
|
||||
name: "Valid JSON object",
|
||||
input: `{"key": "value"}`,
|
||||
expected: `{"key": "value"}`,
|
||||
},
|
||||
{
|
||||
name: "Valid JSON array",
|
||||
input: `[1, 2, "three"]`,
|
||||
expected: `[1, 2, "three"]`,
|
||||
},
|
||||
{
|
||||
name: "Fake JSON (starts with { but doesn't end with })",
|
||||
input: `{"key": "value"`, // Missing closing bracket, has spaces
|
||||
expected: `"{\"key\": \"value\""`,
|
||||
},
|
||||
{
|
||||
name: "Empty JSON (object)",
|
||||
input: `{ }`,
|
||||
expected: `{ }`,
|
||||
},
|
||||
|
||||
// 7. Edge Cases
|
||||
{
|
||||
name: "Empty string",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Whitespace only string",
|
||||
input: " ",
|
||||
expected: `" "`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
actual := formatFieldValue(tt.input)
|
||||
if actual != tt.expected {
|
||||
t.Errorf("formatFieldValue() = %q, expected %q", actual, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
415
pkg/providers/anthropic_messages/provider.go
Normal file
415
pkg/providers/anthropic_messages/provider.go
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package anthropicmessages
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
||||
type (
|
||||
ToolCall = protocoltypes.ToolCall
|
||||
FunctionCall = protocoltypes.FunctionCall
|
||||
LLMResponse = protocoltypes.LLMResponse
|
||||
UsageInfo = protocoltypes.UsageInfo
|
||||
Message = protocoltypes.Message
|
||||
ToolDefinition = protocoltypes.ToolDefinition
|
||||
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAPIVersion = "2023-06-01"
|
||||
defaultBaseURL = "https://api.anthropic.com/v1"
|
||||
defaultRequestTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
// Provider implements Anthropic Messages API via HTTP (without SDK).
|
||||
// It supports custom endpoints that use Anthropic's native message format.
|
||||
type Provider struct {
|
||||
apiKey string
|
||||
apiBase string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewProvider creates a new Anthropic Messages API provider.
|
||||
func NewProvider(apiKey, apiBase string) *Provider {
|
||||
return NewProviderWithTimeout(apiKey, apiBase, 0)
|
||||
}
|
||||
|
||||
// NewProviderWithTimeout creates a provider with custom request timeout.
|
||||
func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provider {
|
||||
baseURL := normalizeBaseURL(apiBase)
|
||||
timeout := defaultRequestTimeout
|
||||
if timeoutSeconds > 0 {
|
||||
timeout = time.Duration(timeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
return &Provider{
|
||||
apiKey: apiKey,
|
||||
apiBase: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Chat sends messages to the Anthropic Messages API and returns the response.
|
||||
func (p *Provider) Chat(
|
||||
ctx context.Context,
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
if p.apiKey == "" {
|
||||
return nil, fmt.Errorf("API key not configured")
|
||||
}
|
||||
|
||||
// Build request body
|
||||
requestBody, err := buildRequestBody(messages, tools, model, options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building request body: %w", err)
|
||||
}
|
||||
|
||||
// Serialize to JSON
|
||||
jsonBody, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("serializing request body: %w", err)
|
||||
}
|
||||
|
||||
// Build request URL
|
||||
endpointURL, err := url.JoinPath(p.apiBase, "messages")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building endpoint URL: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", endpointURL, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating HTTP request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-API-Key", p.apiKey) //nolint:canonicalheader // Anthropic API requires exact header name
|
||||
req.Header.Set("Anthropic-Version", defaultAPIVersion)
|
||||
|
||||
// Execute request
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("executing HTTP request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
|
||||
// Check for HTTP errors with detailed messages
|
||||
switch resp.StatusCode {
|
||||
case http.StatusUnauthorized:
|
||||
return nil, fmt.Errorf("authentication failed (401): check your API key")
|
||||
case http.StatusTooManyRequests:
|
||||
return nil, fmt.Errorf("rate limited (429): %s", string(body))
|
||||
case http.StatusBadRequest:
|
||||
return nil, fmt.Errorf("bad request (400): %s", string(body))
|
||||
case http.StatusNotFound:
|
||||
return nil, fmt.Errorf("endpoint not found (404): %s", string(body))
|
||||
case http.StatusInternalServerError:
|
||||
return nil, fmt.Errorf("internal server error (500): %s", string(body))
|
||||
case http.StatusServiceUnavailable:
|
||||
return nil, fmt.Errorf("service unavailable (503): %s", string(body))
|
||||
default:
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// Parse response
|
||||
return parseResponseBody(body)
|
||||
}
|
||||
|
||||
// GetDefaultModel returns the default model for this provider.
|
||||
func (p *Provider) GetDefaultModel() string {
|
||||
return "claude-sonnet-4.6"
|
||||
}
|
||||
|
||||
// buildRequestBody converts internal message format to Anthropic Messages API format.
|
||||
func buildRequestBody(
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) (map[string]any, error) {
|
||||
// max_tokens is required and guaranteed by agent loop
|
||||
maxTokens, ok := asInt(options["max_tokens"])
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("max_tokens is required in options")
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"model": model,
|
||||
"max_tokens": int64(maxTokens),
|
||||
"messages": []any{},
|
||||
}
|
||||
|
||||
// Set temperature from options
|
||||
if temp, ok := asFloat(options["temperature"]); ok {
|
||||
result["temperature"] = temp
|
||||
}
|
||||
|
||||
// Process messages
|
||||
var systemPrompt string
|
||||
var apiMessages []any
|
||||
|
||||
for _, msg := range messages {
|
||||
switch msg.Role {
|
||||
case "system":
|
||||
// Accumulate system messages
|
||||
if systemPrompt != "" {
|
||||
systemPrompt += "\n\n" + msg.Content
|
||||
} else {
|
||||
systemPrompt = msg.Content
|
||||
}
|
||||
|
||||
case "user":
|
||||
if msg.ToolCallID != "" {
|
||||
// Tool result message
|
||||
content := []map[string]any{
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": msg.ToolCallID,
|
||||
"content": msg.Content,
|
||||
},
|
||||
}
|
||||
apiMessages = append(apiMessages, map[string]any{
|
||||
"role": "user",
|
||||
"content": content,
|
||||
})
|
||||
} else {
|
||||
// Regular user message
|
||||
apiMessages = append(apiMessages, map[string]any{
|
||||
"role": "user",
|
||||
"content": msg.Content,
|
||||
})
|
||||
}
|
||||
|
||||
case "assistant":
|
||||
content := []any{}
|
||||
|
||||
// Add text content if present
|
||||
if msg.Content != "" {
|
||||
content = append(content, map[string]any{
|
||||
"type": "text",
|
||||
"text": msg.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Add tool_use blocks
|
||||
for _, tc := range msg.ToolCalls {
|
||||
toolUse := map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": tc.ID,
|
||||
"name": tc.Name,
|
||||
"input": tc.Arguments,
|
||||
}
|
||||
content = append(content, toolUse)
|
||||
}
|
||||
|
||||
apiMessages = append(apiMessages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
})
|
||||
|
||||
case "tool":
|
||||
// Tool result (alternative format)
|
||||
content := []map[string]any{
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": msg.ToolCallID,
|
||||
"content": msg.Content,
|
||||
},
|
||||
}
|
||||
apiMessages = append(apiMessages, map[string]any{
|
||||
"role": "user",
|
||||
"content": content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
result["messages"] = apiMessages
|
||||
|
||||
// Set system prompt if present
|
||||
if systemPrompt != "" {
|
||||
result["system"] = systemPrompt
|
||||
}
|
||||
|
||||
// Add tools if present
|
||||
if len(tools) > 0 {
|
||||
result["tools"] = buildTools(tools)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// buildTools converts tool definitions to Anthropic format.
|
||||
func buildTools(tools []ToolDefinition) []any {
|
||||
result := make([]any, len(tools))
|
||||
for i, tool := range tools {
|
||||
toolDef := map[string]any{
|
||||
"name": tool.Function.Name,
|
||||
"description": tool.Function.Description,
|
||||
"input_schema": tool.Function.Parameters,
|
||||
}
|
||||
result[i] = toolDef
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseResponseBody parses Anthropic Messages API response.
|
||||
func parseResponseBody(body []byte) (*LLMResponse, error) {
|
||||
var resp anthropicMessageResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("parsing JSON response: %w", err)
|
||||
}
|
||||
|
||||
// Extract content and tool calls
|
||||
var content strings.Builder
|
||||
toolCalls := make([]ToolCall, 0) // Initialize as empty slice (not nil) for consistent JSON serialization
|
||||
|
||||
for _, block := range resp.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
content.WriteString(block.Text)
|
||||
case "tool_use":
|
||||
argsJSON, _ := json.Marshal(block.Input)
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: block.ID,
|
||||
Name: block.Name,
|
||||
Arguments: block.Input,
|
||||
Function: &FunctionCall{
|
||||
Name: block.Name,
|
||||
Arguments: string(argsJSON),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Map stop_reason
|
||||
finishReason := "stop"
|
||||
switch resp.StopReason {
|
||||
case "tool_use":
|
||||
finishReason = "tool_calls"
|
||||
case "max_tokens":
|
||||
finishReason = "length"
|
||||
case "end_turn":
|
||||
finishReason = "stop"
|
||||
case "stop_sequence":
|
||||
finishReason = "stop"
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: content.String(),
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: finishReason,
|
||||
Usage: &UsageInfo{
|
||||
PromptTokens: int(resp.Usage.InputTokens),
|
||||
CompletionTokens: int(resp.Usage.OutputTokens),
|
||||
TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normalizeBaseURL ensures the base URL is properly formatted.
|
||||
// It removes /v1 suffix if present (to avoid duplication) and always appends /v1.
|
||||
// This handles edge cases like "https://api.example.com/v1/proxy" correctly.
|
||||
func normalizeBaseURL(apiBase string) string {
|
||||
base := strings.TrimSpace(apiBase)
|
||||
if base == "" {
|
||||
return defaultBaseURL
|
||||
}
|
||||
|
||||
// Remove trailing slashes
|
||||
base = strings.TrimRight(base, "/")
|
||||
|
||||
// Remove /v1 suffix if present (will be re-added)
|
||||
// This prevents duplication for URLs like "https://api.example.com/v1/proxy"
|
||||
if before, ok := strings.CutSuffix(base, "/v1"); ok {
|
||||
base = before
|
||||
}
|
||||
|
||||
// Ensure we don't have an empty string after cutting
|
||||
if base == "" {
|
||||
return defaultBaseURL
|
||||
}
|
||||
|
||||
// Add /v1 suffix (required by Anthropic Messages API)
|
||||
return base + "/v1"
|
||||
}
|
||||
|
||||
// Helper functions for type conversion
|
||||
|
||||
func asInt(v any) (int, bool) {
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return val, true
|
||||
case float64:
|
||||
return int(val), true
|
||||
case int64:
|
||||
return int(val), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func asFloat(v any) (float64, bool) {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val, true
|
||||
case int:
|
||||
return float64(val), true
|
||||
case int64:
|
||||
return float64(val), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// Anthropic API response structures
|
||||
|
||||
type anthropicMessageResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Content []contentBlock `json:"content"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Model string `json:"model"`
|
||||
Usage usageInfo `json:"usage"`
|
||||
}
|
||||
|
||||
type contentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input map[string]any `json:"input,omitempty"`
|
||||
}
|
||||
|
||||
type usageInfo struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
}
|
||||
622
pkg/providers/anthropic_messages/provider_test.go
Normal file
622
pkg/providers/anthropic_messages/provider_test.go
Normal file
|
|
@ -0,0 +1,622 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package anthropicmessages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildRequestBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
messages []Message
|
||||
tools []ToolDefinition
|
||||
model string
|
||||
options map[string]any
|
||||
want map[string]any
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic user message",
|
||||
messages: []Message{
|
||||
{Role: "user", Content: "Hello, world!"},
|
||||
},
|
||||
model: "test-model",
|
||||
options: map[string]any{
|
||||
"max_tokens": 8192,
|
||||
},
|
||||
want: map[string]any{
|
||||
"model": "test-model",
|
||||
"max_tokens": int64(8192),
|
||||
"messages": []any{
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": "Hello, world!",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user and assistant messages",
|
||||
messages: []Message{
|
||||
{Role: "user", Content: "What is 2+2?"},
|
||||
{Role: "assistant", Content: "4"},
|
||||
},
|
||||
model: "test-model",
|
||||
options: map[string]any{
|
||||
"max_tokens": 8192,
|
||||
},
|
||||
want: map[string]any{
|
||||
"model": "test-model",
|
||||
"max_tokens": int64(8192),
|
||||
"messages": []any{
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": "What is 2+2?",
|
||||
},
|
||||
map[string]any{
|
||||
"role": "assistant",
|
||||
"content": []any{
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": "4",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with system message",
|
||||
messages: []Message{
|
||||
{Role: "system", Content: "You are a helpful assistant."},
|
||||
{Role: "user", Content: "Hello"},
|
||||
},
|
||||
model: "test-model",
|
||||
options: map[string]any{
|
||||
"max_tokens": 8192,
|
||||
},
|
||||
want: map[string]any{
|
||||
"model": "test-model",
|
||||
"max_tokens": int64(8192),
|
||||
"system": "You are a helpful assistant.",
|
||||
"messages": []any{
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": "Hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with custom max_tokens and temperature",
|
||||
messages: []Message{
|
||||
{Role: "user", Content: "Test"},
|
||||
},
|
||||
model: "test-model",
|
||||
options: map[string]any{
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0.5,
|
||||
},
|
||||
want: map[string]any{
|
||||
"model": "test-model",
|
||||
"max_tokens": int64(2048),
|
||||
"temperature": 0.5,
|
||||
"messages": []any{
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": "Test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing max_tokens returns error",
|
||||
messages: []Message{
|
||||
{Role: "user", Content: "Test"},
|
||||
},
|
||||
model: "test-model",
|
||||
options: map[string]any{},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "with tools",
|
||||
messages: []Message{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
},
|
||||
tools: []ToolDefinition{
|
||||
{
|
||||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get current weather",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"location": map[string]any{
|
||||
"type": "string",
|
||||
"description": "City name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
model: "test-model",
|
||||
options: map[string]any{
|
||||
"max_tokens": 8192,
|
||||
},
|
||||
want: map[string]any{
|
||||
"model": "test-model",
|
||||
"max_tokens": int64(8192),
|
||||
"messages": []any{
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": "What's the weather?",
|
||||
},
|
||||
},
|
||||
"tools": []any{
|
||||
map[string]any{
|
||||
"name": "get_weather",
|
||||
"description": "Get current weather",
|
||||
"input_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"location": map[string]any{
|
||||
"type": "string",
|
||||
"description": "City name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := buildRequestBody(tt.messages, tt.tools, tt.model, tt.options)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("buildRequestBody() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
gotJSON, _ := json.MarshalIndent(got, "", " ")
|
||||
wantJSON, _ := json.MarshalIndent(tt.want, "", " ")
|
||||
t.Errorf("buildRequestBody() mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResponseBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
want *LLMResponse
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic text response",
|
||||
body: []byte(`{
|
||||
"id": "msg-123",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello, how can I help?"}
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"model": "test-model",
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 5
|
||||
}
|
||||
}`),
|
||||
want: &LLMResponse{
|
||||
Content: "Hello, how can I help?",
|
||||
ToolCalls: []ToolCall{},
|
||||
FinishReason: "stop",
|
||||
Usage: &UsageInfo{
|
||||
PromptTokens: 10,
|
||||
CompletionTokens: 5,
|
||||
TotalTokens: 15,
|
||||
},
|
||||
Reasoning: "",
|
||||
ReasoningDetails: nil,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "response with tool use",
|
||||
body: []byte(`{
|
||||
"id": "msg-456",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "I'll check the weather for you."},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu-123",
|
||||
"name": "get_weather",
|
||||
"input": {"location": "Tokyo"}
|
||||
}
|
||||
],
|
||||
"stop_reason": "tool_use",
|
||||
"model": "test-model",
|
||||
"usage": {
|
||||
"input_tokens": 20,
|
||||
"output_tokens": 15
|
||||
}
|
||||
}`),
|
||||
want: &LLMResponse{
|
||||
Content: "I'll check the weather for you.",
|
||||
ToolCalls: []ToolCall{
|
||||
{
|
||||
ID: "toolu-123",
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{
|
||||
"location": "Tokyo",
|
||||
},
|
||||
Function: &FunctionCall{
|
||||
Name: "get_weather",
|
||||
Arguments: `{"location":"Tokyo"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
FinishReason: "tool_calls",
|
||||
Usage: &UsageInfo{
|
||||
PromptTokens: 20,
|
||||
CompletionTokens: 15,
|
||||
TotalTokens: 35,
|
||||
},
|
||||
Reasoning: "",
|
||||
ReasoningDetails: nil,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
body: []byte(`invalid json`),
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "max_tokens stop reason",
|
||||
body: []byte(`{
|
||||
"id": "msg-789",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "Partial response"}
|
||||
],
|
||||
"stop_reason": "max_tokens",
|
||||
"model": "test-model",
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 4096
|
||||
}
|
||||
}`),
|
||||
want: &LLMResponse{
|
||||
Content: "Partial response",
|
||||
ToolCalls: []ToolCall{},
|
||||
FinishReason: "length",
|
||||
Usage: &UsageInfo{
|
||||
PromptTokens: 100,
|
||||
CompletionTokens: 4096,
|
||||
TotalTokens: 4196,
|
||||
},
|
||||
Reasoning: "",
|
||||
ReasoningDetails: nil,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseResponseBody(tt.body)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseResponseBody() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Compare individual fields
|
||||
if got.Content != tt.want.Content {
|
||||
t.Errorf("Content = %q, want %q", got.Content, tt.want.Content)
|
||||
}
|
||||
if got.FinishReason != tt.want.FinishReason {
|
||||
t.Errorf("FinishReason = %q, want %q", got.FinishReason, tt.want.FinishReason)
|
||||
}
|
||||
if got.Usage == nil && tt.want.Usage != nil {
|
||||
t.Errorf("Usage = nil, want non-nil")
|
||||
} else if got.Usage != nil && tt.want.Usage == nil {
|
||||
t.Errorf("Usage = non-nil, want nil")
|
||||
} else if got.Usage != nil && tt.want.Usage != nil {
|
||||
if got.Usage.PromptTokens != tt.want.Usage.PromptTokens {
|
||||
t.Errorf("Usage.PromptTokens = %d, want %d", got.Usage.PromptTokens, tt.want.Usage.PromptTokens)
|
||||
}
|
||||
if got.Usage.CompletionTokens != tt.want.Usage.CompletionTokens {
|
||||
t.Errorf("Usage.CompletionTokens = %d, want %d",
|
||||
got.Usage.CompletionTokens, tt.want.Usage.CompletionTokens)
|
||||
}
|
||||
if got.Usage.TotalTokens != tt.want.Usage.TotalTokens {
|
||||
t.Errorf("Usage.TotalTokens = %d, want %d", got.Usage.TotalTokens, tt.want.Usage.TotalTokens)
|
||||
}
|
||||
}
|
||||
if len(got.ToolCalls) != len(tt.want.ToolCalls) {
|
||||
t.Errorf("ToolCalls length = %d, want %d", len(got.ToolCalls), len(tt.want.ToolCalls))
|
||||
} else {
|
||||
for i := range got.ToolCalls {
|
||||
if got.ToolCalls[i].ID != tt.want.ToolCalls[i].ID {
|
||||
t.Errorf("ToolCalls[%d].ID = %q, want %q",
|
||||
i, got.ToolCalls[i].ID, tt.want.ToolCalls[i].ID)
|
||||
}
|
||||
if got.ToolCalls[i].Name != tt.want.ToolCalls[i].Name {
|
||||
t.Errorf("ToolCalls[%d].Name = %q, want %q",
|
||||
i, got.ToolCalls[i].Name, tt.want.ToolCalls[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiBase string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty string defaults to official API",
|
||||
apiBase: "",
|
||||
expected: "https://api.anthropic.com/v1",
|
||||
},
|
||||
{
|
||||
name: "URL without /v1 gets it appended",
|
||||
apiBase: "https://api.example.com/anthropic",
|
||||
expected: "https://api.example.com/anthropic/v1",
|
||||
},
|
||||
{
|
||||
name: "URL with /v1 remains unchanged",
|
||||
apiBase: "https://api.example.com/v1",
|
||||
expected: "https://api.example.com/v1",
|
||||
},
|
||||
{
|
||||
name: "URL with trailing slash gets cleaned",
|
||||
apiBase: "https://api.example.com/anthropic/",
|
||||
expected: "https://api.example.com/anthropic/v1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := normalizeBaseURL(tt.apiBase)
|
||||
if got != tt.expected {
|
||||
t.Errorf("normalizeBaseURL(%q) = %q, want %q", tt.apiBase, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewProvider(t *testing.T) {
|
||||
provider := NewProvider("test-key", "https://api.example.com")
|
||||
if provider == nil {
|
||||
t.Fatal("NewProvider() returned nil")
|
||||
}
|
||||
if provider.apiKey != "test-key" {
|
||||
t.Errorf("provider.apiKey = %q, want %q", provider.apiKey, "test-key")
|
||||
}
|
||||
if provider.apiBase != "https://api.example.com/v1" {
|
||||
t.Errorf("provider.apiBase = %q, want %q", provider.apiBase, "https://api.example.com/v1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDefaultModel(t *testing.T) {
|
||||
provider := NewProvider("test-key", "")
|
||||
got := provider.GetDefaultModel()
|
||||
expected := "claude-sonnet-4.6"
|
||||
if got != expected {
|
||||
t.Errorf("GetDefaultModel() = %q, want %q", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRequestBodyEdgeCases tests edge cases for buildRequestBody.
|
||||
func TestBuildRequestBodyEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
messages []Message
|
||||
tools []ToolDefinition
|
||||
model string
|
||||
options map[string]any
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty message list",
|
||||
messages: []Message{},
|
||||
model: "test-model",
|
||||
options: map[string]any{
|
||||
"max_tokens": 8192,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "very long system message",
|
||||
messages: []Message{
|
||||
{Role: "system", Content: strings.Repeat("This is a very long system prompt. ", 1000)},
|
||||
{Role: "user", Content: "Hello"},
|
||||
},
|
||||
model: "test-model",
|
||||
options: map[string]any{
|
||||
"max_tokens": 8192,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "multiple consecutive system messages",
|
||||
messages: []Message{
|
||||
{Role: "system", Content: "First system message"},
|
||||
{Role: "system", Content: "Second system message"},
|
||||
{Role: "system", Content: "Third system message"},
|
||||
{Role: "user", Content: "Hello"},
|
||||
},
|
||||
model: "test-model",
|
||||
options: map[string]any{
|
||||
"max_tokens": 8192,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "tool result without tool call",
|
||||
messages: []Message{
|
||||
{Role: "user", Content: "Use a tool"},
|
||||
{Role: "assistant", Content: "", ToolCalls: []ToolCall{
|
||||
{ID: "tool-1", Name: "test_tool", Arguments: map[string]any{"arg": "value"}},
|
||||
}},
|
||||
{Role: "user", ToolCallID: "tool-1", Content: "Tool result"},
|
||||
},
|
||||
model: "test-model",
|
||||
options: map[string]any{
|
||||
"max_tokens": 8192,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := buildRequestBody(tt.messages, tt.tools, tt.model, tt.options)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("buildRequestBody() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify basic structure
|
||||
if got == nil {
|
||||
t.Error("buildRequestBody() returned nil")
|
||||
return
|
||||
}
|
||||
if got["model"] != tt.model {
|
||||
t.Errorf("model = %v, want %v", got["model"], tt.model)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseResponseBodyEdgeCases tests edge cases for parseResponseBody.
|
||||
func TestParseResponseBodyEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
wantErr bool
|
||||
check func(*testing.T, *LLMResponse)
|
||||
}{
|
||||
{
|
||||
name: "empty content blocks",
|
||||
body: []byte(`{
|
||||
"id": "msg-empty",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [],
|
||||
"stop_reason": "end_turn",
|
||||
"model": "test-model",
|
||||
"usage": {"input_tokens": 5, "output_tokens": 0}
|
||||
}`),
|
||||
wantErr: false,
|
||||
check: func(t *testing.T, resp *LLMResponse) {
|
||||
if resp.Content != "" {
|
||||
t.Errorf("Content = %q, want empty string", resp.Content)
|
||||
}
|
||||
if len(resp.ToolCalls) != 0 {
|
||||
t.Errorf("ToolCalls length = %d, want 0", len(resp.ToolCalls))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple tool use blocks",
|
||||
body: []byte(`{
|
||||
"id": "msg-multi",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "tool-1", "name": "func1", "input": {"arg": "val1"}},
|
||||
{"type": "tool_use", "id": "tool-2", "name": "func2", "input": {"arg": "val2"}}
|
||||
],
|
||||
"stop_reason": "tool_use",
|
||||
"model": "test-model",
|
||||
"usage": {"input_tokens": 10, "output_tokens": 20}
|
||||
}`),
|
||||
wantErr: false,
|
||||
check: func(t *testing.T, resp *LLMResponse) {
|
||||
if len(resp.ToolCalls) != 2 {
|
||||
t.Errorf("ToolCalls length = %d, want 2", len(resp.ToolCalls))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "malformed JSON response",
|
||||
body: []byte(`{invalid json`),
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseResponseBody(tt.body)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseResponseBody() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.check != nil && err == nil {
|
||||
tt.check(t, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderChatErrors tests error handling in Chat.
|
||||
// Note: apiBase check removed as it's dead code - normalizeBaseURL() always provides a default.
|
||||
func TestProviderChatErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiKey string
|
||||
messages []Message
|
||||
wantErrMsg string
|
||||
}{
|
||||
{
|
||||
name: "missing API key",
|
||||
apiKey: "",
|
||||
messages: []Message{{Role: "user", Content: "Test"}},
|
||||
wantErrMsg: "API key not configured",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create provider using constructor to ensure proper initialization
|
||||
provider := NewProvider(tt.apiKey, "https://api.example.com")
|
||||
|
||||
_, err := provider.Chat(context.Background(), tt.messages, nil, "test-model", nil)
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error, got nil")
|
||||
}
|
||||
if err.Error() != tt.wantErrMsg {
|
||||
t.Errorf("Chat() error = %q, want %q", err.Error(), tt.wantErrMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
150
pkg/providers/azure/provider.go
Normal file
150
pkg/providers/azure/provider.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package azure
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
||||
type (
|
||||
LLMResponse = protocoltypes.LLMResponse
|
||||
Message = protocoltypes.Message
|
||||
ToolDefinition = protocoltypes.ToolDefinition
|
||||
)
|
||||
|
||||
const (
|
||||
// azureAPIVersion is the Azure OpenAI API version used for all requests.
|
||||
azureAPIVersion = "2024-10-21"
|
||||
defaultRequestTimeout = common.DefaultRequestTimeout
|
||||
)
|
||||
|
||||
// Provider implements the LLM provider interface for Azure OpenAI endpoints.
|
||||
// It handles Azure-specific authentication (api-key header), URL construction
|
||||
// (deployment-based), and request body formatting (max_completion_tokens, no model field).
|
||||
type Provider struct {
|
||||
apiKey string
|
||||
apiBase string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Option configures the Azure Provider.
|
||||
type Option func(*Provider)
|
||||
|
||||
// WithRequestTimeout sets the HTTP request timeout.
|
||||
func WithRequestTimeout(timeout time.Duration) Option {
|
||||
return func(p *Provider) {
|
||||
if timeout > 0 {
|
||||
p.httpClient.Timeout = timeout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewProvider creates a new Azure OpenAI provider.
|
||||
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
|
||||
p := &Provider{
|
||||
apiKey: apiKey,
|
||||
apiBase: strings.TrimRight(apiBase, "/"),
|
||||
httpClient: common.NewHTTPClient(proxy),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(p)
|
||||
}
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// NewProviderWithTimeout creates a new Azure OpenAI provider with a custom request timeout in seconds.
|
||||
func NewProviderWithTimeout(apiKey, apiBase, proxy string, requestTimeoutSeconds int) *Provider {
|
||||
return NewProvider(
|
||||
apiKey, apiBase, proxy,
|
||||
WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
||||
)
|
||||
}
|
||||
|
||||
// Chat sends a chat completion request to the Azure OpenAI endpoint.
|
||||
// The model parameter is used as the Azure deployment name in the URL.
|
||||
func (p *Provider) Chat(
|
||||
ctx context.Context,
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
if p.apiBase == "" {
|
||||
return nil, fmt.Errorf("Azure API base not configured")
|
||||
}
|
||||
|
||||
// model is the deployment name for Azure OpenAI
|
||||
deployment := model
|
||||
|
||||
// Build Azure-specific URL safely using url.JoinPath and query encoding
|
||||
// to prevent path traversal or query injection via deployment names.
|
||||
base, err := url.JoinPath(p.apiBase, "openai/deployments", deployment, "chat/completions")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build Azure request URL: %w", err)
|
||||
}
|
||||
requestURL := base + "?api-version=" + azureAPIVersion
|
||||
|
||||
// Build request body — no "model" field (Azure infers from deployment URL)
|
||||
requestBody := map[string]any{
|
||||
"messages": common.SerializeMessages(messages),
|
||||
}
|
||||
|
||||
if len(tools) > 0 {
|
||||
requestBody["tools"] = tools
|
||||
requestBody["tool_choice"] = "auto"
|
||||
}
|
||||
|
||||
// Azure OpenAI always uses max_completion_tokens
|
||||
if maxTokens, ok := common.AsInt(options["max_tokens"]); ok {
|
||||
requestBody["max_completion_tokens"] = maxTokens
|
||||
}
|
||||
|
||||
if temperature, ok := common.AsFloat(options["temperature"]); ok {
|
||||
requestBody["temperature"] = temperature
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", requestURL, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Azure uses api-key header instead of Authorization: Bearer
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if p.apiKey != "" {
|
||||
req.Header.Set("Api-Key", p.apiKey)
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, common.HandleErrorResponse(resp, p.apiBase)
|
||||
}
|
||||
|
||||
return common.ReadAndParseResponse(resp, p.apiBase)
|
||||
}
|
||||
|
||||
// GetDefaultModel returns an empty string as Azure deployments are user-configured.
|
||||
func (p *Provider) GetDefaultModel() string {
|
||||
return ""
|
||||
}
|
||||
232
pkg/providers/azure/provider_test.go
Normal file
232
pkg/providers/azure/provider_test.go
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
package azure
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeValidResponse writes a minimal valid Azure OpenAI chat completion response.
|
||||
func writeValidResponse(w http.ResponseWriter) {
|
||||
resp := map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{
|
||||
"message": map[string]any{"content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func TestProviderChat_AzureURLConstruction(t *testing.T) {
|
||||
var capturedPath string
|
||||
var capturedAPIVersion string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedPath = r.URL.Path
|
||||
capturedAPIVersion = r.URL.Query().Get("api-version")
|
||||
writeValidResponse(w)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("test-key", server.URL, "")
|
||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-gpt5-deployment", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
wantPath := "/openai/deployments/my-gpt5-deployment/chat/completions"
|
||||
if capturedPath != wantPath {
|
||||
t.Errorf("URL path = %q, want %q", capturedPath, wantPath)
|
||||
}
|
||||
if capturedAPIVersion != azureAPIVersion {
|
||||
t.Errorf("api-version = %q, want %q", capturedAPIVersion, azureAPIVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_AzureAuthHeader(t *testing.T) {
|
||||
var capturedAPIKey string
|
||||
var capturedAuth string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedAPIKey = r.Header.Get("Api-Key")
|
||||
capturedAuth = r.Header.Get("Authorization")
|
||||
writeValidResponse(w)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("test-azure-key", server.URL, "")
|
||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
if capturedAPIKey != "test-azure-key" {
|
||||
t.Errorf("api-key header = %q, want %q", capturedAPIKey, "test-azure-key")
|
||||
}
|
||||
if capturedAuth != "" {
|
||||
t.Errorf("Authorization header should be empty, got %q", capturedAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_AzureOmitsModelFromBody(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewDecoder(r.Body).Decode(&requestBody)
|
||||
writeValidResponse(w)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("test-key", server.URL, "")
|
||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
if _, exists := requestBody["model"]; exists {
|
||||
t.Error("request body should not contain 'model' field for Azure OpenAI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_AzureUsesMaxCompletionTokens(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewDecoder(r.Body).Decode(&requestBody)
|
||||
writeValidResponse(w)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("test-key", server.URL, "")
|
||||
_, err := p.Chat(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "hi"}},
|
||||
nil,
|
||||
"deployment",
|
||||
map[string]any{"max_tokens": 2048},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
if _, exists := requestBody["max_completion_tokens"]; !exists {
|
||||
t.Error("request body should contain 'max_completion_tokens'")
|
||||
}
|
||||
if _, exists := requestBody["max_tokens"]; exists {
|
||||
t.Error("request body should not contain 'max_tokens'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_AzureHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("bad-key", server.URL, "")
|
||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_AzureParseToolCalls(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{
|
||||
"message": map[string]any{
|
||||
"content": "",
|
||||
"tool_calls": []map[string]any{
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": "get_weather",
|
||||
"arguments": `{"city":"Seattle"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("test-key", server.URL, "")
|
||||
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "weather?"}}, nil, "deployment", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
if len(out.ToolCalls) != 1 {
|
||||
t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
|
||||
}
|
||||
if out.ToolCalls[0].Name != "get_weather" {
|
||||
t.Errorf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_AzureEmptyAPIBase(t *testing.T) {
|
||||
p := NewProvider("test-key", "", "")
|
||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty API base")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_AzureRequestTimeoutDefault(t *testing.T) {
|
||||
p := NewProvider("test-key", "https://example.com", "")
|
||||
if p.httpClient.Timeout != defaultRequestTimeout {
|
||||
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_AzureRequestTimeoutOverride(t *testing.T) {
|
||||
p := NewProvider("test-key", "https://example.com", "", WithRequestTimeout(300*time.Second))
|
||||
if p.httpClient.Timeout != 300*time.Second {
|
||||
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_AzureNewProviderWithTimeout(t *testing.T) {
|
||||
p := NewProviderWithTimeout("test-key", "https://example.com", "", 180)
|
||||
if p.httpClient.Timeout != 180*time.Second {
|
||||
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 180*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_AzureDeploymentNameEscaped(t *testing.T) {
|
||||
var capturedPath string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedPath = r.URL.RawPath // use RawPath to see percent-encoding
|
||||
if capturedPath == "" {
|
||||
capturedPath = r.URL.Path
|
||||
}
|
||||
writeValidResponse(w)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("test-key", server.URL, "")
|
||||
|
||||
// Deployment name with characters that could cause path injection
|
||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my deploy/../../admin", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
// The slash and special chars in the deployment name must be escaped, not treated as path separators
|
||||
if capturedPath == "/openai/deployments/my deploy/../../admin/chat/completions" {
|
||||
t.Fatal("deployment name was interpolated without escaping — path injection possible")
|
||||
}
|
||||
}
|
||||
380
pkg/providers/common/common.go
Normal file
380
pkg/providers/common/common.go
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
// Package common provides shared utilities used by multiple LLM provider
|
||||
// implementations (openai_compat, azure, etc.).
|
||||
package common
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
||||
// Re-export protocol types used across providers.
|
||||
type (
|
||||
ToolCall = protocoltypes.ToolCall
|
||||
FunctionCall = protocoltypes.FunctionCall
|
||||
LLMResponse = protocoltypes.LLMResponse
|
||||
UsageInfo = protocoltypes.UsageInfo
|
||||
Message = protocoltypes.Message
|
||||
ToolDefinition = protocoltypes.ToolDefinition
|
||||
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||
ExtraContent = protocoltypes.ExtraContent
|
||||
GoogleExtra = protocoltypes.GoogleExtra
|
||||
ReasoningDetail = protocoltypes.ReasoningDetail
|
||||
)
|
||||
|
||||
const DefaultRequestTimeout = 120 * time.Second
|
||||
|
||||
// NewHTTPClient creates an *http.Client with an optional proxy and the default timeout.
|
||||
func NewHTTPClient(proxy string) *http.Client {
|
||||
client := &http.Client{
|
||||
Timeout: DefaultRequestTimeout,
|
||||
}
|
||||
if proxy != "" {
|
||||
parsed, err := url.Parse(proxy)
|
||||
if err == nil {
|
||||
// Preserve http.DefaultTransport settings (TLS, HTTP/2, timeouts, etc.)
|
||||
if base, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||
tr := base.Clone()
|
||||
tr.Proxy = http.ProxyURL(parsed)
|
||||
client.Transport = tr
|
||||
} else {
|
||||
// Fallback: minimal transport if DefaultTransport is not *http.Transport.
|
||||
client.Transport = &http.Transport{
|
||||
Proxy: http.ProxyURL(parsed),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("common: invalid proxy URL %q: %v", proxy, err)
|
||||
}
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// --- Message serialization ---
|
||||
|
||||
// openaiMessage is the wire-format message for OpenAI-compatible APIs.
|
||||
// It mirrors protocoltypes.Message but omits SystemParts, which is an
|
||||
// internal field that would be unknown to third-party endpoints.
|
||||
type openaiMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
// SerializeMessages converts internal Message structs to the OpenAI wire format.
|
||||
// - Strips SystemParts (unknown to third-party endpoints)
|
||||
// - Converts messages with Media to multipart content format (text + image_url parts)
|
||||
// - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages
|
||||
func SerializeMessages(messages []Message) []any {
|
||||
out := make([]any, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
if len(m.Media) == 0 {
|
||||
out = append(out, openaiMessage{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
ReasoningContent: m.ReasoningContent,
|
||||
ToolCalls: m.ToolCalls,
|
||||
ToolCallID: m.ToolCallID,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Multipart content format for messages with media
|
||||
parts := make([]map[string]any, 0, 1+len(m.Media))
|
||||
if m.Content != "" {
|
||||
parts = append(parts, map[string]any{
|
||||
"type": "text",
|
||||
"text": m.Content,
|
||||
})
|
||||
}
|
||||
for _, mediaURL := range m.Media {
|
||||
if strings.HasPrefix(mediaURL, "data:image/") {
|
||||
parts = append(parts, map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{
|
||||
"url": mediaURL,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
msg := map[string]any{
|
||||
"role": m.Role,
|
||||
"content": parts,
|
||||
}
|
||||
if m.ToolCallID != "" {
|
||||
msg["tool_call_id"] = m.ToolCallID
|
||||
}
|
||||
if len(m.ToolCalls) > 0 {
|
||||
msg["tool_calls"] = m.ToolCalls
|
||||
}
|
||||
if m.ReasoningContent != "" {
|
||||
msg["reasoning_content"] = m.ReasoningContent
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Response parsing ---
|
||||
|
||||
// ParseResponse parses a JSON chat completion response body into an LLMResponse.
|
||||
func ParseResponse(body io.Reader) (*LLMResponse, error) {
|
||||
var apiResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function *struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
} `json:"function"`
|
||||
ExtraContent *struct {
|
||||
Google *struct {
|
||||
ThoughtSignature string `json:"thought_signature"`
|
||||
} `json:"google"`
|
||||
} `json:"extra_content"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage *UsageInfo `json:"usage"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(body).Decode(&apiResponse); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if len(apiResponse.Choices) == 0 {
|
||||
return &LLMResponse{
|
||||
Content: "",
|
||||
FinishReason: "stop",
|
||||
}, nil
|
||||
}
|
||||
|
||||
choice := apiResponse.Choices[0]
|
||||
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
arguments := make(map[string]any)
|
||||
name := ""
|
||||
|
||||
// Extract thought_signature from Gemini/Google-specific extra content
|
||||
thoughtSignature := ""
|
||||
if tc.ExtraContent != nil && tc.ExtraContent.Google != nil {
|
||||
thoughtSignature = tc.ExtraContent.Google.ThoughtSignature
|
||||
}
|
||||
|
||||
if tc.Function != nil {
|
||||
name = tc.Function.Name
|
||||
arguments = DecodeToolCallArguments(tc.Function.Arguments, name)
|
||||
}
|
||||
|
||||
toolCall := ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: name,
|
||||
Arguments: arguments,
|
||||
ThoughtSignature: thoughtSignature,
|
||||
}
|
||||
|
||||
if thoughtSignature != "" {
|
||||
toolCall.ExtraContent = &ExtraContent{
|
||||
Google: &GoogleExtra{
|
||||
ThoughtSignature: thoughtSignature,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
toolCalls = append(toolCalls, toolCall)
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: choice.Message.Content,
|
||||
ReasoningContent: choice.Message.ReasoningContent,
|
||||
Reasoning: choice.Message.Reasoning,
|
||||
ReasoningDetails: choice.Message.ReasoningDetails,
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: choice.FinishReason,
|
||||
Usage: apiResponse.Usage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DecodeToolCallArguments decodes a tool call's arguments from raw JSON.
|
||||
func DecodeToolCallArguments(raw json.RawMessage, name string) map[string]any {
|
||||
arguments := make(map[string]any)
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
return arguments
|
||||
}
|
||||
|
||||
var decoded any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
log.Printf("common: failed to decode tool call arguments payload for %q: %v", name, err)
|
||||
arguments["raw"] = string(raw)
|
||||
return arguments
|
||||
}
|
||||
|
||||
switch v := decoded.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return arguments
|
||||
}
|
||||
if err := json.Unmarshal([]byte(v), &arguments); err != nil {
|
||||
log.Printf("common: failed to decode tool call arguments for %q: %v", name, err)
|
||||
arguments["raw"] = v
|
||||
}
|
||||
return arguments
|
||||
case map[string]any:
|
||||
return v
|
||||
default:
|
||||
log.Printf("common: unsupported tool call arguments type for %q: %T", name, decoded)
|
||||
arguments["raw"] = string(raw)
|
||||
return arguments
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTP response helpers ---
|
||||
|
||||
// HandleErrorResponse reads a non-200 response body and returns an appropriate error.
|
||||
func HandleErrorResponse(resp *http.Response, apiBase string) error {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("failed to read response: %w", readErr)
|
||||
}
|
||||
if LooksLikeHTML(body, contentType) {
|
||||
return WrapHTMLResponseError(resp.StatusCode, body, contentType, apiBase)
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"API request failed:\n Status: %d\n Body: %s",
|
||||
resp.StatusCode,
|
||||
ResponsePreview(body, 128),
|
||||
)
|
||||
}
|
||||
|
||||
// ReadAndParseResponse peeks at the response body to detect HTML errors,
|
||||
// then parses the JSON response into an LLMResponse.
|
||||
func ReadAndParseResponse(resp *http.Response, apiBase string) (*LLMResponse, error) {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
prefix, err := reader.Peek(256)
|
||||
if err != nil && err != io.EOF && err != bufio.ErrBufferFull {
|
||||
return nil, fmt.Errorf("failed to inspect response: %w", err)
|
||||
}
|
||||
if LooksLikeHTML(prefix, contentType) {
|
||||
return nil, WrapHTMLResponseError(resp.StatusCode, prefix, contentType, apiBase)
|
||||
}
|
||||
out, err := ParseResponse(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LooksLikeHTML checks if the response body appears to be HTML.
|
||||
func LooksLikeHTML(body []byte, contentType string) bool {
|
||||
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
||||
if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") {
|
||||
return true
|
||||
}
|
||||
prefix := bytes.ToLower(leadingTrimmedPrefix(body, 128))
|
||||
return bytes.HasPrefix(prefix, []byte("<!doctype html")) ||
|
||||
bytes.HasPrefix(prefix, []byte("<html")) ||
|
||||
bytes.HasPrefix(prefix, []byte("<head")) ||
|
||||
bytes.HasPrefix(prefix, []byte("<body"))
|
||||
}
|
||||
|
||||
// WrapHTMLResponseError creates a descriptive error for HTML responses.
|
||||
func WrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error {
|
||||
respPreview := ResponsePreview(body, 128)
|
||||
return fmt.Errorf(
|
||||
"API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s",
|
||||
apiBase,
|
||||
contentType,
|
||||
statusCode,
|
||||
respPreview,
|
||||
)
|
||||
}
|
||||
|
||||
// ResponsePreview returns a truncated preview of response body for error messages.
|
||||
func ResponsePreview(body []byte, maxLen int) string {
|
||||
trimmed := bytes.TrimSpace(body)
|
||||
if len(trimmed) == 0 {
|
||||
return "<empty>"
|
||||
}
|
||||
if len(trimmed) <= maxLen {
|
||||
return string(trimmed)
|
||||
}
|
||||
return string(trimmed[:maxLen]) + "..."
|
||||
}
|
||||
|
||||
func leadingTrimmedPrefix(body []byte, maxLen int) []byte {
|
||||
i := 0
|
||||
for i < len(body) {
|
||||
switch body[i] {
|
||||
case ' ', '\t', '\n', '\r', '\f', '\v':
|
||||
i++
|
||||
default:
|
||||
end := i + maxLen
|
||||
if end > len(body) {
|
||||
end = len(body)
|
||||
}
|
||||
return body[i:end]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Numeric helpers ---
|
||||
|
||||
// AsInt converts various numeric types to int.
|
||||
func AsInt(v any) (int, bool) {
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return val, true
|
||||
case int64:
|
||||
return int(val), true
|
||||
case float64:
|
||||
return int(val), true
|
||||
case float32:
|
||||
return int(val), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// AsFloat converts various numeric types to float64.
|
||||
func AsFloat(v any) (float64, bool) {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val, true
|
||||
case float32:
|
||||
return float64(val), true
|
||||
case int:
|
||||
return float64(val), true
|
||||
case int64:
|
||||
return float64(val), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
558
pkg/providers/common/common_test.go
Normal file
558
pkg/providers/common/common_test.go
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
||||
// --- NewHTTPClient tests ---
|
||||
|
||||
func TestNewHTTPClient_DefaultTimeout(t *testing.T) {
|
||||
client := NewHTTPClient("")
|
||||
if client.Timeout != DefaultRequestTimeout {
|
||||
t.Errorf("timeout = %v, want %v", client.Timeout, DefaultRequestTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHTTPClient_WithProxy(t *testing.T) {
|
||||
client := NewHTTPClient("http://127.0.0.1:8080")
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok || transport == nil {
|
||||
t.Fatalf("expected http.Transport with proxy, got %T", client.Transport)
|
||||
}
|
||||
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.example.com"}}
|
||||
gotProxy, err := transport.Proxy(req)
|
||||
if err != nil {
|
||||
t.Fatalf("proxy function error: %v", err)
|
||||
}
|
||||
if gotProxy == nil || gotProxy.String() != "http://127.0.0.1:8080" {
|
||||
t.Errorf("proxy = %v, want http://127.0.0.1:8080", gotProxy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHTTPClient_NoProxy(t *testing.T) {
|
||||
client := NewHTTPClient("")
|
||||
if client.Transport != nil {
|
||||
t.Errorf("expected nil transport without proxy, got %T", client.Transport)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHTTPClient_InvalidProxy(t *testing.T) {
|
||||
// Should not panic, just log and return client without proxy
|
||||
client := NewHTTPClient("://bad-url")
|
||||
if client == nil {
|
||||
t.Fatal("expected non-nil client even with invalid proxy")
|
||||
}
|
||||
}
|
||||
|
||||
// --- SerializeMessages tests ---
|
||||
|
||||
func TestSerializeMessages_PlainText(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{Role: "assistant", Content: "hi", ReasoningContent: "thinking..."},
|
||||
}
|
||||
result := SerializeMessages(messages)
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var msgs []map[string]any
|
||||
json.Unmarshal(data, &msgs)
|
||||
|
||||
if msgs[0]["content"] != "hello" {
|
||||
t.Errorf("expected plain string content, got %v", msgs[0]["content"])
|
||||
}
|
||||
if msgs[1]["reasoning_content"] != "thinking..." {
|
||||
t.Errorf("reasoning_content not preserved, got %v", msgs[1]["reasoning_content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializeMessages_WithMedia(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}},
|
||||
}
|
||||
result := SerializeMessages(messages)
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var msgs []map[string]any
|
||||
json.Unmarshal(data, &msgs)
|
||||
|
||||
content, ok := msgs[0]["content"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected array content for media message, got %T", msgs[0]["content"])
|
||||
}
|
||||
if len(content) != 2 {
|
||||
t.Fatalf("expected 2 content parts, got %d", len(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializeMessages_MediaWithToolCallID(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "tool", Content: "result", Media: []string{"data:image/png;base64,xyz"}, ToolCallID: "call_1"},
|
||||
}
|
||||
result := SerializeMessages(messages)
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var msgs []map[string]any
|
||||
json.Unmarshal(data, &msgs)
|
||||
|
||||
if msgs[0]["tool_call_id"] != "call_1" {
|
||||
t.Errorf("tool_call_id not preserved, got %v", msgs[0]["tool_call_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializeMessages_StripsSystemParts(t *testing.T) {
|
||||
messages := []Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: "you are helpful",
|
||||
SystemParts: []protocoltypes.ContentBlock{
|
||||
{Type: "text", Text: "you are helpful"},
|
||||
},
|
||||
},
|
||||
}
|
||||
result := SerializeMessages(messages)
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
if strings.Contains(string(data), "system_parts") {
|
||||
t.Error("system_parts should not appear in serialized output")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ParseResponse tests ---
|
||||
|
||||
func TestParseResponse_BasicContent(t *testing.T) {
|
||||
body := `{"choices":[{"message":{"content":"hello world"},"finish_reason":"stop"}]}`
|
||||
out, err := ParseResponse(strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseResponse() error = %v", err)
|
||||
}
|
||||
if out.Content != "hello world" {
|
||||
t.Errorf("Content = %q, want %q", out.Content, "hello world")
|
||||
}
|
||||
if out.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", out.FinishReason, "stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResponse_EmptyChoices(t *testing.T) {
|
||||
body := `{"choices":[]}`
|
||||
out, err := ParseResponse(strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseResponse() error = %v", err)
|
||||
}
|
||||
if out.Content != "" {
|
||||
t.Errorf("Content = %q, want empty", out.Content)
|
||||
}
|
||||
if out.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", out.FinishReason, "stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResponse_WithToolCalls(t *testing.T) {
|
||||
body := `{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"SF\"}"}}]},"finish_reason":"tool_calls"}]}`
|
||||
out, err := ParseResponse(strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseResponse() error = %v", err)
|
||||
}
|
||||
if len(out.ToolCalls) != 1 {
|
||||
t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
|
||||
}
|
||||
if out.ToolCalls[0].Name != "get_weather" {
|
||||
t.Errorf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather")
|
||||
}
|
||||
if out.ToolCalls[0].Arguments["city"] != "SF" {
|
||||
t.Errorf("ToolCalls[0].Arguments[city] = %v, want SF", out.ToolCalls[0].Arguments["city"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResponse_WithUsage(t *testing.T) {
|
||||
body := `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`
|
||||
out, err := ParseResponse(strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseResponse() error = %v", err)
|
||||
}
|
||||
if out.Usage == nil {
|
||||
t.Fatal("Usage is nil")
|
||||
}
|
||||
if out.Usage.PromptTokens != 10 {
|
||||
t.Errorf("PromptTokens = %d, want 10", out.Usage.PromptTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResponse_WithReasoningContent(t *testing.T) {
|
||||
body := `{"choices":[{"message":{"content":"2","reasoning_content":"Let me think... 1+1=2"},"finish_reason":"stop"}]}`
|
||||
out, err := ParseResponse(strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseResponse() error = %v", err)
|
||||
}
|
||||
if out.ReasoningContent != "Let me think... 1+1=2" {
|
||||
t.Errorf("ReasoningContent = %q, want %q", out.ReasoningContent, "Let me think... 1+1=2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResponse_InvalidJSON(t *testing.T) {
|
||||
_, err := ParseResponse(strings.NewReader("not json"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// --- DecodeToolCallArguments tests ---
|
||||
|
||||
func TestDecodeToolCallArguments_ObjectJSON(t *testing.T) {
|
||||
raw := json.RawMessage(`{"city":"Seattle","units":"metric"}`)
|
||||
args := DecodeToolCallArguments(raw, "test")
|
||||
if args["city"] != "Seattle" {
|
||||
t.Errorf("city = %v, want Seattle", args["city"])
|
||||
}
|
||||
if args["units"] != "metric" {
|
||||
t.Errorf("units = %v, want metric", args["units"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeToolCallArguments_StringJSON(t *testing.T) {
|
||||
raw := json.RawMessage(`"{\"city\":\"SF\"}"`)
|
||||
args := DecodeToolCallArguments(raw, "test")
|
||||
if args["city"] != "SF" {
|
||||
t.Errorf("city = %v, want SF", args["city"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeToolCallArguments_EmptyInput(t *testing.T) {
|
||||
args := DecodeToolCallArguments(nil, "test")
|
||||
if len(args) != 0 {
|
||||
t.Errorf("expected empty map, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeToolCallArguments_NullInput(t *testing.T) {
|
||||
args := DecodeToolCallArguments(json.RawMessage(`null`), "test")
|
||||
if len(args) != 0 {
|
||||
t.Errorf("expected empty map, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeToolCallArguments_InvalidJSON(t *testing.T) {
|
||||
args := DecodeToolCallArguments(json.RawMessage(`not-json`), "test")
|
||||
if _, ok := args["raw"]; !ok {
|
||||
t.Error("expected 'raw' fallback key for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeToolCallArguments_EmptyStringJSON(t *testing.T) {
|
||||
args := DecodeToolCallArguments(json.RawMessage(`" "`), "test")
|
||||
if len(args) != 0 {
|
||||
t.Errorf("expected empty map for whitespace string, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// --- HandleErrorResponse tests ---
|
||||
|
||||
func TestHandleErrorResponse_JSONError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"error":"bad request"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("http.Get() error = %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
err = HandleErrorResponse(resp, server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "400") {
|
||||
t.Errorf("error should contain status code, got %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "HTML") {
|
||||
t.Errorf("should not mention HTML for JSON error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleErrorResponse_HTMLError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
w.Write([]byte("<!DOCTYPE html><html><body>bad gateway</body></html>"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("http.Get() error = %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
err = HandleErrorResponse(resp, server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTML instead of JSON") {
|
||||
t.Errorf("expected HTML error message, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ReadAndParseResponse tests ---
|
||||
|
||||
func TestReadAndParseResponse_ValidJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("http.Get() error = %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, err := ReadAndParseResponse(resp, server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAndParseResponse() error = %v", err)
|
||||
}
|
||||
if out.Content != "ok" {
|
||||
t.Errorf("Content = %q, want %q", out.Content, "ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAndParseResponse_HTMLResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte("<!DOCTYPE html><html><body>login page</body></html>"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("http.Get() error = %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, err = ReadAndParseResponse(resp, server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTML response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTML instead of JSON") {
|
||||
t.Errorf("expected HTML error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- LooksLikeHTML tests ---
|
||||
|
||||
func TestLooksLikeHTML_ContentTypeHTML(t *testing.T) {
|
||||
if !LooksLikeHTML(nil, "text/html; charset=utf-8") {
|
||||
t.Error("expected true for text/html content type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeHTML_ContentTypeXHTML(t *testing.T) {
|
||||
if !LooksLikeHTML(nil, "application/xhtml+xml") {
|
||||
t.Error("expected true for xhtml content type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeHTML_BodyPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"doctype", "<!DOCTYPE html><html>"},
|
||||
{"html tag", "<html><body>"},
|
||||
{"head tag", "<head><title>"},
|
||||
{"body tag", "<body>content"},
|
||||
{"whitespace before", " \n\t<!DOCTYPE html>"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if !LooksLikeHTML([]byte(tt.body), "application/json") {
|
||||
t.Errorf("expected true for body %q", tt.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeHTML_NotHTML(t *testing.T) {
|
||||
if LooksLikeHTML([]byte(`{"error":"bad"}`), "application/json") {
|
||||
t.Error("expected false for JSON body")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ResponsePreview tests ---
|
||||
|
||||
func TestResponsePreview_Short(t *testing.T) {
|
||||
got := ResponsePreview([]byte("hello"), 128)
|
||||
if got != "hello" {
|
||||
t.Errorf("got %q, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsePreview_Truncated(t *testing.T) {
|
||||
body := strings.Repeat("a", 200)
|
||||
got := ResponsePreview([]byte(body), 128)
|
||||
if len(got) != 131 { // 128 + "..."
|
||||
t.Errorf("len = %d, want 131", len(got))
|
||||
}
|
||||
if !strings.HasSuffix(got, "...") {
|
||||
t.Error("expected ... suffix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsePreview_Empty(t *testing.T) {
|
||||
got := ResponsePreview([]byte(""), 128)
|
||||
if got != "<empty>" {
|
||||
t.Errorf("got %q, want %q", got, "<empty>")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsePreview_Whitespace(t *testing.T) {
|
||||
got := ResponsePreview([]byte(" \n\t "), 128)
|
||||
if got != "<empty>" {
|
||||
t.Errorf("got %q, want %q for whitespace-only body", got, "<empty>")
|
||||
}
|
||||
}
|
||||
|
||||
// --- AsInt tests ---
|
||||
|
||||
func TestAsInt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
val any
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{"int", 42, 42, true},
|
||||
{"int64", int64(99), 99, true},
|
||||
{"float64", float64(512), 512, true},
|
||||
{"float32", float32(256), 256, true},
|
||||
{"string", "nope", 0, false},
|
||||
{"nil", nil, 0, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := AsInt(tt.val)
|
||||
if ok != tt.ok || got != tt.want {
|
||||
t.Errorf("AsInt(%v) = (%d, %v), want (%d, %v)", tt.val, got, ok, tt.want, tt.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- AsFloat tests ---
|
||||
|
||||
func TestAsFloat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
val any
|
||||
want float64
|
||||
ok bool
|
||||
}{
|
||||
{"float64", float64(0.7), 0.7, true},
|
||||
{"float32", float32(0.5), float64(float32(0.5)), true},
|
||||
{"int", 1, 1.0, true},
|
||||
{"int64", int64(100), 100.0, true},
|
||||
{"string", "nope", 0, false},
|
||||
{"nil", nil, 0, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := AsFloat(tt.val)
|
||||
if ok != tt.ok || got != tt.want {
|
||||
t.Errorf("AsFloat(%v) = (%f, %v), want (%f, %v)", tt.val, got, ok, tt.want, tt.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- WrapHTMLResponseError tests ---
|
||||
|
||||
func TestWrapHTMLResponseError(t *testing.T) {
|
||||
err := WrapHTMLResponseError(502, []byte("<html>bad</html>"), "text/html", "https://api.example.com")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "502") {
|
||||
t.Errorf("expected status code in error, got %v", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "https://api.example.com") {
|
||||
t.Errorf("expected api base in error, got %v", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "HTML instead of JSON") {
|
||||
t.Errorf("expected HTML mention in error, got %v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// --- HandleErrorResponse with read failure ---
|
||||
|
||||
func TestHandleErrorResponse_EmptyBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
// empty body
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("http.Get() error = %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
err = HandleErrorResponse(resp, server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "500") {
|
||||
t.Errorf("expected status code, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ReadAndParseResponse with invalid JSON ---
|
||||
|
||||
func TestReadAndParseResponse_InvalidJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte("not valid json"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("http.Get() error = %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, err = ReadAndParseResponse(resp, server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ParseResponse with thought_signature (Google/Gemini) ---
|
||||
|
||||
func TestParseResponse_WithThoughtSignature(t *testing.T) {
|
||||
body := `{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"test_tool","arguments":"{}"},"extra_content":{"google":{"thought_signature":"sig123"}}}]},"finish_reason":"tool_calls"}]}`
|
||||
out, err := ParseResponse(strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseResponse() error = %v", err)
|
||||
}
|
||||
if len(out.ToolCalls) != 1 {
|
||||
t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
|
||||
}
|
||||
if out.ToolCalls[0].ThoughtSignature != "sig123" {
|
||||
t.Errorf("ThoughtSignature = %q, want %q", out.ToolCalls[0].ThoughtSignature, "sig123")
|
||||
}
|
||||
if out.ToolCalls[0].ExtraContent == nil || out.ToolCalls[0].ExtraContent.Google == nil {
|
||||
t.Fatal("ExtraContent.Google is nil")
|
||||
}
|
||||
if out.ToolCalls[0].ExtraContent.Google.ThoughtSignature != "sig123" {
|
||||
t.Errorf("ExtraContent.Google.ThoughtSignature = %q, want %q",
|
||||
out.ToolCalls[0].ExtraContent.Google.ThoughtSignature, "sig123")
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/azure"
|
||||
)
|
||||
|
||||
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
|
||||
|
|
@ -53,7 +55,8 @@ func ExtractProtocol(model string) (protocol, modelID string) {
|
|||
|
||||
// CreateProviderFromConfig creates a provider based on the ModelConfig.
|
||||
// It uses the protocol prefix in the Model field to determine which provider to create.
|
||||
// Supported protocols: openai, litellm, anthropic, antigravity, claude-cli, codex-cli, github-copilot
|
||||
// Supported protocols: openai, litellm, anthropic, anthropic-messages, antigravity,
|
||||
// claude-cli, codex-cli, github-copilot
|
||||
// Returns the provider, the model ID (without protocol prefix), and any error.
|
||||
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
|
||||
if cfg == nil {
|
||||
|
|
@ -92,10 +95,28 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
cfg.RequestTimeout,
|
||||
), modelID, nil
|
||||
|
||||
case "azure", "azure-openai":
|
||||
// Azure OpenAI uses deployment-based URLs, api-key header auth,
|
||||
// and always sends max_completion_tokens.
|
||||
if cfg.APIKey == "" {
|
||||
return nil, "", fmt.Errorf("api_key is required for azure protocol")
|
||||
}
|
||||
if cfg.APIBase == "" {
|
||||
return nil, "", fmt.Errorf(
|
||||
"api_base is required for azure protocol (e.g., https://your-resource.openai.azure.com)",
|
||||
)
|
||||
}
|
||||
return azure.NewProviderWithTimeout(
|
||||
cfg.APIKey,
|
||||
cfg.APIBase,
|
||||
cfg.Proxy,
|
||||
cfg.RequestTimeout,
|
||||
), modelID, nil
|
||||
|
||||
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
|
||||
"minimax", "longcat":
|
||||
"minimax", "longcat", "modelscope":
|
||||
// All other OpenAI-compatible HTTP providers
|
||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||
|
|
@ -137,6 +158,21 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
cfg.RequestTimeout,
|
||||
), modelID, nil
|
||||
|
||||
case "anthropic-messages":
|
||||
// Anthropic Messages API with native format (HTTP-based, no SDK)
|
||||
apiBase := cfg.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.anthropic.com/v1"
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
return nil, "", fmt.Errorf("api_key is required for anthropic-messages protocol (model: %s)", cfg.Model)
|
||||
}
|
||||
return anthropicmessages.NewProviderWithTimeout(
|
||||
cfg.APIKey,
|
||||
apiBase,
|
||||
cfg.RequestTimeout,
|
||||
), modelID, nil
|
||||
|
||||
case "antigravity":
|
||||
return NewAntigravityProvider(), modelID, nil
|
||||
|
||||
|
|
@ -217,6 +253,8 @@ func getDefaultAPIBase(protocol string) string {
|
|||
return "https://api.minimaxi.com/v1"
|
||||
case "longcat":
|
||||
return "https://api.longcat.chat/openai"
|
||||
case "modelscope":
|
||||
return "https://api-inference.modelscope.cn/v1"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@ func TestExtractProtocol(t *testing.T) {
|
|||
wantProtocol: "nvidia",
|
||||
wantModelID: "meta/llama-3.1-8b",
|
||||
},
|
||||
{
|
||||
name: "azure with prefix",
|
||||
model: "azure/my-gpt5-deployment",
|
||||
wantProtocol: "azure",
|
||||
wantModelID: "my-gpt5-deployment",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -114,6 +120,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
|
|||
{"deepseek", "deepseek"},
|
||||
{"ollama", "ollama"},
|
||||
{"longcat", "longcat"},
|
||||
{"modelscope", "modelscope"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -186,6 +193,35 @@ func TestCreateProviderFromConfig_LongCat(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_ModelScope(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-modelscope",
|
||||
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
APIKey: "test-key",
|
||||
APIBase: "https://api-inference.modelscope.cn/v1",
|
||||
}
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||
}
|
||||
if provider == nil {
|
||||
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||
}
|
||||
if modelID != "Qwen/Qwen3-235B-A22B-Instruct-2507" {
|
||||
t.Errorf("modelID = %q, want %q", modelID, "Qwen/Qwen3-235B-A22B-Instruct-2507")
|
||||
}
|
||||
if _, ok := provider.(*HTTPProvider); !ok {
|
||||
t.Fatalf("expected *HTTPProvider, got %T", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDefaultAPIBase_ModelScope(t *testing.T) {
|
||||
if got := getDefaultAPIBase("modelscope"); got != "https://api-inference.modelscope.cn/v1" {
|
||||
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "modelscope", got, "https://api-inference.modelscope.cn/v1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-anthropic",
|
||||
|
|
@ -341,3 +377,69 @@ func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) {
|
|||
t.Fatalf("Chat() error = %q, want timeout-related error", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_Azure(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "azure-gpt5",
|
||||
Model: "azure/my-gpt5-deployment",
|
||||
APIKey: "test-azure-key",
|
||||
APIBase: "https://my-resource.openai.azure.com",
|
||||
}
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||
}
|
||||
if provider == nil {
|
||||
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||
}
|
||||
if modelID != "my-gpt5-deployment" {
|
||||
t.Errorf("modelID = %q, want %q", modelID, "my-gpt5-deployment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_AzureOpenAIAlias(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "azure-gpt4",
|
||||
Model: "azure-openai/my-deployment",
|
||||
APIKey: "test-azure-key",
|
||||
APIBase: "https://my-resource.openai.azure.com",
|
||||
}
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||
}
|
||||
if provider == nil {
|
||||
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||
}
|
||||
if modelID != "my-deployment" {
|
||||
t.Errorf("modelID = %q, want %q", modelID, "my-deployment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_AzureMissingAPIKey(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "azure-gpt5",
|
||||
Model: "azure/my-gpt5-deployment",
|
||||
APIBase: "https://my-resource.openai.azure.com",
|
||||
}
|
||||
|
||||
_, _, err := CreateProviderFromConfig(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("CreateProviderFromConfig() expected error for missing API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_AzureMissingAPIBase(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "azure-gpt5",
|
||||
Model: "azure/my-gpt5-deployment",
|
||||
APIKey: "test-azure-key",
|
||||
}
|
||||
|
||||
_, _, err := CreateProviderFromConfig(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("CreateProviderFromConfig() expected error for missing API base")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
package openai_compat
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
||||
|
|
@ -38,7 +36,7 @@ type Provider struct {
|
|||
|
||||
type Option func(*Provider)
|
||||
|
||||
const defaultRequestTimeout = 120 * time.Second
|
||||
const defaultRequestTimeout = common.DefaultRequestTimeout
|
||||
|
||||
func WithMaxTokensField(maxTokensField string) Option {
|
||||
return func(p *Provider) {
|
||||
|
|
@ -55,25 +53,10 @@ func WithRequestTimeout(timeout time.Duration) Option {
|
|||
}
|
||||
|
||||
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
|
||||
client := &http.Client{
|
||||
Timeout: defaultRequestTimeout,
|
||||
}
|
||||
|
||||
if proxy != "" {
|
||||
parsed, err := url.Parse(proxy)
|
||||
if err == nil {
|
||||
client.Transport = &http.Transport{
|
||||
Proxy: http.ProxyURL(parsed),
|
||||
}
|
||||
} else {
|
||||
log.Printf("openai_compat: invalid proxy URL %q: %v", proxy, err)
|
||||
}
|
||||
}
|
||||
|
||||
p := &Provider{
|
||||
apiKey: apiKey,
|
||||
apiBase: strings.TrimRight(apiBase, "/"),
|
||||
httpClient: client,
|
||||
httpClient: common.NewHTTPClient(proxy),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
|
|
@ -117,7 +100,7 @@ func (p *Provider) Chat(
|
|||
|
||||
requestBody := map[string]any{
|
||||
"model": model,
|
||||
"messages": serializeMessages(messages),
|
||||
"messages": common.SerializeMessages(messages),
|
||||
}
|
||||
|
||||
if len(tools) > 0 {
|
||||
|
|
@ -125,7 +108,7 @@ func (p *Provider) Chat(
|
|||
requestBody["tool_choice"] = "auto"
|
||||
}
|
||||
|
||||
if maxTokens, ok := asInt(options["max_tokens"]); ok {
|
||||
if maxTokens, ok := common.AsInt(options["max_tokens"]); ok {
|
||||
// Use configured maxTokensField if specified, otherwise fallback to model-based detection
|
||||
fieldName := p.maxTokensField
|
||||
if fieldName == "" {
|
||||
|
|
@ -141,7 +124,7 @@ func (p *Provider) Chat(
|
|||
requestBody[fieldName] = maxTokens
|
||||
}
|
||||
|
||||
if temperature, ok := asFloat(options["temperature"]); ok {
|
||||
if temperature, ok := common.AsFloat(options["temperature"]); ok {
|
||||
lowerModel := strings.ToLower(model)
|
||||
// Kimi k2 models only support temperature=1.
|
||||
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
|
||||
|
|
@ -185,275 +168,11 @@ func (p *Provider) Chat(
|
|||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
|
||||
// Non-200: read a prefix to tell HTML error page apart from JSON error body.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", readErr)
|
||||
}
|
||||
if looksLikeHTML(body, contentType) {
|
||||
return nil, wrapHTMLResponseError(resp.StatusCode, body, contentType, p.apiBase)
|
||||
}
|
||||
return nil, fmt.Errorf(
|
||||
"API request failed:\n Status: %d\n Body: %s",
|
||||
resp.StatusCode,
|
||||
responsePreview(body, 128),
|
||||
)
|
||||
return nil, common.HandleErrorResponse(resp, p.apiBase)
|
||||
}
|
||||
|
||||
// Peek without consuming so the full stream reaches the JSON decoder.
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
prefix, err := reader.Peek(256) // io.EOF/ErrBufferFull are normal; only real errors abort
|
||||
if err != nil && err != io.EOF && err != bufio.ErrBufferFull {
|
||||
return nil, fmt.Errorf("failed to inspect response: %w", err)
|
||||
}
|
||||
if looksLikeHTML(prefix, contentType) {
|
||||
return nil, wrapHTMLResponseError(resp.StatusCode, prefix, contentType, p.apiBase)
|
||||
}
|
||||
|
||||
out, err := parseResponse(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func wrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error {
|
||||
respPreview := responsePreview(body, 128)
|
||||
return fmt.Errorf(
|
||||
"API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s",
|
||||
apiBase,
|
||||
contentType,
|
||||
statusCode,
|
||||
respPreview,
|
||||
)
|
||||
}
|
||||
|
||||
func looksLikeHTML(body []byte, contentType string) bool {
|
||||
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
||||
if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") {
|
||||
return true
|
||||
}
|
||||
prefix := bytes.ToLower(leadingTrimmedPrefix(body, 128))
|
||||
return bytes.HasPrefix(prefix, []byte("<!doctype html")) ||
|
||||
bytes.HasPrefix(prefix, []byte("<html")) ||
|
||||
bytes.HasPrefix(prefix, []byte("<head")) ||
|
||||
bytes.HasPrefix(prefix, []byte("<body"))
|
||||
}
|
||||
|
||||
func leadingTrimmedPrefix(body []byte, maxLen int) []byte {
|
||||
i := 0
|
||||
for i < len(body) {
|
||||
switch body[i] {
|
||||
case ' ', '\t', '\n', '\r', '\f', '\v':
|
||||
i++
|
||||
default:
|
||||
end := i + maxLen
|
||||
if end > len(body) {
|
||||
end = len(body)
|
||||
}
|
||||
return body[i:end]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func responsePreview(body []byte, maxLen int) string {
|
||||
trimmed := bytes.TrimSpace(body)
|
||||
if len(trimmed) == 0 {
|
||||
return "<empty>"
|
||||
}
|
||||
if len(trimmed) <= maxLen {
|
||||
return string(trimmed)
|
||||
}
|
||||
return string(trimmed[:maxLen]) + "..."
|
||||
}
|
||||
|
||||
func parseResponse(body io.Reader) (*LLMResponse, error) {
|
||||
var apiResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function *struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
} `json:"function"`
|
||||
ExtraContent *struct {
|
||||
Google *struct {
|
||||
ThoughtSignature string `json:"thought_signature"`
|
||||
} `json:"google"`
|
||||
} `json:"extra_content"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage *UsageInfo `json:"usage"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(body).Decode(&apiResponse); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if len(apiResponse.Choices) == 0 {
|
||||
return &LLMResponse{
|
||||
Content: "",
|
||||
FinishReason: "stop",
|
||||
}, nil
|
||||
}
|
||||
|
||||
choice := apiResponse.Choices[0]
|
||||
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
arguments := make(map[string]any)
|
||||
name := ""
|
||||
|
||||
// Extract thought_signature from Gemini/Google-specific extra content
|
||||
thoughtSignature := ""
|
||||
if tc.ExtraContent != nil && tc.ExtraContent.Google != nil {
|
||||
thoughtSignature = tc.ExtraContent.Google.ThoughtSignature
|
||||
}
|
||||
|
||||
if tc.Function != nil {
|
||||
name = tc.Function.Name
|
||||
arguments = decodeToolCallArguments(tc.Function.Arguments, name)
|
||||
}
|
||||
|
||||
// Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence
|
||||
toolCall := ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: name,
|
||||
Arguments: arguments,
|
||||
ThoughtSignature: thoughtSignature,
|
||||
}
|
||||
|
||||
if thoughtSignature != "" {
|
||||
toolCall.ExtraContent = &ExtraContent{
|
||||
Google: &GoogleExtra{
|
||||
ThoughtSignature: thoughtSignature,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
toolCalls = append(toolCalls, toolCall)
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: choice.Message.Content,
|
||||
ReasoningContent: choice.Message.ReasoningContent,
|
||||
Reasoning: choice.Message.Reasoning,
|
||||
ReasoningDetails: choice.Message.ReasoningDetails,
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: choice.FinishReason,
|
||||
Usage: apiResponse.Usage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeToolCallArguments(raw json.RawMessage, name string) map[string]any {
|
||||
arguments := make(map[string]any)
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
return arguments
|
||||
}
|
||||
|
||||
var decoded any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
log.Printf("openai_compat: failed to decode tool call arguments payload for %q: %v", name, err)
|
||||
arguments["raw"] = string(raw)
|
||||
return arguments
|
||||
}
|
||||
|
||||
switch v := decoded.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return arguments
|
||||
}
|
||||
if err := json.Unmarshal([]byte(v), &arguments); err != nil {
|
||||
log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
|
||||
arguments["raw"] = v
|
||||
}
|
||||
return arguments
|
||||
case map[string]any:
|
||||
return v
|
||||
default:
|
||||
log.Printf("openai_compat: unsupported tool call arguments type for %q: %T", name, decoded)
|
||||
arguments["raw"] = string(raw)
|
||||
return arguments
|
||||
}
|
||||
}
|
||||
|
||||
// openaiMessage is the wire-format message for OpenAI-compatible APIs.
|
||||
// It mirrors protocoltypes.Message but omits SystemParts, which is an
|
||||
// internal field that would be unknown to third-party endpoints.
|
||||
type openaiMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
// serializeMessages converts internal Message structs to the OpenAI wire format.
|
||||
// - Strips SystemParts (unknown to third-party endpoints)
|
||||
// - Converts messages with Media to multipart content format (text + image_url parts)
|
||||
// - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages
|
||||
func serializeMessages(messages []Message) []any {
|
||||
out := make([]any, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
if len(m.Media) == 0 {
|
||||
out = append(out, openaiMessage{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
ReasoningContent: m.ReasoningContent,
|
||||
ToolCalls: m.ToolCalls,
|
||||
ToolCallID: m.ToolCallID,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Multipart content format for messages with media
|
||||
parts := make([]map[string]any, 0, 1+len(m.Media))
|
||||
if m.Content != "" {
|
||||
parts = append(parts, map[string]any{
|
||||
"type": "text",
|
||||
"text": m.Content,
|
||||
})
|
||||
}
|
||||
for _, mediaURL := range m.Media {
|
||||
if strings.HasPrefix(mediaURL, "data:image/") {
|
||||
parts = append(parts, map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{
|
||||
"url": mediaURL,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
msg := map[string]any{
|
||||
"role": m.Role,
|
||||
"content": parts,
|
||||
}
|
||||
if m.ToolCallID != "" {
|
||||
msg["tool_call_id"] = m.ToolCallID
|
||||
}
|
||||
if len(m.ToolCalls) > 0 {
|
||||
msg["tool_calls"] = m.ToolCalls
|
||||
}
|
||||
if m.ReasoningContent != "" {
|
||||
msg["reasoning_content"] = m.ReasoningContent
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out
|
||||
return common.ReadAndParseResponse(resp, p.apiBase)
|
||||
}
|
||||
|
||||
func normalizeModel(model, apiBase string) string {
|
||||
|
|
@ -476,36 +195,6 @@ func normalizeModel(model, apiBase string) string {
|
|||
}
|
||||
}
|
||||
|
||||
func asInt(v any) (int, bool) {
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return val, true
|
||||
case int64:
|
||||
return int(val), true
|
||||
case float64:
|
||||
return int(val), true
|
||||
case float32:
|
||||
return int(val), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func asFloat(v any) (float64, bool) {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val, true
|
||||
case float32:
|
||||
return float64(val), true
|
||||
case int:
|
||||
return float64(val), true
|
||||
case int64:
|
||||
return float64(val), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// supportsPromptCacheKey reports whether the given API base is known to
|
||||
// support the prompt_cache_key request field. Currently only OpenAI's own
|
||||
// API and Azure OpenAI support this. All other OpenAI-compatible providers
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
||||
|
|
@ -648,7 +649,7 @@ func TestSerializeMessages_PlainText(t *testing.T) {
|
|||
{Role: "user", Content: "hello"},
|
||||
{Role: "assistant", Content: "hi", ReasoningContent: "thinking..."},
|
||||
}
|
||||
result := serializeMessages(messages)
|
||||
result := common.SerializeMessages(messages)
|
||||
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
|
|
@ -670,7 +671,7 @@ func TestSerializeMessages_WithMedia(t *testing.T) {
|
|||
messages := []protocoltypes.Message{
|
||||
{Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}},
|
||||
}
|
||||
result := serializeMessages(messages)
|
||||
result := common.SerializeMessages(messages)
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var msgs []map[string]any
|
||||
|
|
@ -703,7 +704,7 @@ func TestSerializeMessages_MediaWithToolCallID(t *testing.T) {
|
|||
messages := []protocoltypes.Message{
|
||||
{Role: "tool", Content: "image result", Media: []string{"data:image/png;base64,xyz"}, ToolCallID: "call_1"},
|
||||
}
|
||||
result := serializeMessages(messages)
|
||||
result := common.SerializeMessages(messages)
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var msgs []map[string]any
|
||||
|
|
@ -833,7 +834,7 @@ func TestSerializeMessages_StripsSystemParts(t *testing.T) {
|
|||
},
|
||||
},
|
||||
}
|
||||
result := serializeMessages(messages)
|
||||
result := common.SerializeMessages(messages)
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
raw := string(data)
|
||||
|
|
|
|||
|
|
@ -2,80 +2,289 @@ package skills
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
type SkillInstaller struct {
|
||||
workspace string
|
||||
// GitHubContent represents a file or directory in GitHub API response
|
||||
type GitHubContent struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"` // "file" or "dir"
|
||||
DownloadURL string `json:"download_url"`
|
||||
URL string `json:"url"` // API URL for subdirectories
|
||||
}
|
||||
|
||||
func NewSkillInstaller(workspace string) *SkillInstaller {
|
||||
// GitHubRef represents a parsed GitHub reference
|
||||
type GitHubRef struct {
|
||||
Owner string // Repository owner
|
||||
RepoName string // Repository name
|
||||
Ref string // Git reference (branch, tag, or commit)
|
||||
SubPath string // Path within the repository
|
||||
}
|
||||
|
||||
type SkillInstaller struct {
|
||||
workspace string
|
||||
client *http.Client
|
||||
githubToken string
|
||||
proxy string
|
||||
}
|
||||
|
||||
// NewSkillInstaller creates a new skill installer.
|
||||
// proxy is an optional HTTP/HTTPS/SOCKS5 proxy URL for downloading skills.
|
||||
func NewSkillInstaller(workspace, githubToken, proxy string) (*SkillInstaller, error) {
|
||||
client, err := utils.CreateHTTPClient(proxy, 15*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
|
||||
}
|
||||
|
||||
return &SkillInstaller{
|
||||
workspace: workspace,
|
||||
client: client,
|
||||
githubToken: githubToken,
|
||||
proxy: proxy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseGitHubRef parses a GitHub reference.
|
||||
// Supports: "owner/repo", "owner/repo/path", or full URL like "https://github.com/owner/repo/tree/ref/path"
|
||||
func parseGitHubRef(repo string) (GitHubRef, error) {
|
||||
repo = strings.TrimSpace(repo)
|
||||
|
||||
// Handle full URL
|
||||
if strings.HasPrefix(repo, "http://") || strings.HasPrefix(repo, "https://") {
|
||||
u, err := url.Parse(repo)
|
||||
if err != nil {
|
||||
return GitHubRef{}, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
if len(parts) < 2 {
|
||||
return GitHubRef{}, fmt.Errorf("invalid GitHub URL")
|
||||
}
|
||||
ref := GitHubRef{
|
||||
Owner: parts[0],
|
||||
RepoName: parts[1],
|
||||
Ref: "main",
|
||||
}
|
||||
// Look for /tree/ or /blob/ in the path
|
||||
for i := 2; i < len(parts); i++ {
|
||||
if parts[i] == "tree" || parts[i] == "blob" {
|
||||
if i+1 < len(parts) {
|
||||
ref.Ref = parts[i+1]
|
||||
ref.SubPath = strings.Join(parts[i+2:], "/")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// Handle shorthand format
|
||||
parts := strings.Split(strings.Trim(repo, "/"), "/")
|
||||
if len(parts) < 2 {
|
||||
return GitHubRef{}, fmt.Errorf("invalid format %q: expected 'owner/repo'", repo)
|
||||
}
|
||||
ref := GitHubRef{
|
||||
Owner: parts[0],
|
||||
RepoName: parts[1],
|
||||
Ref: "main",
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
ref.SubPath = strings.Join(parts[2:], "/")
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) error {
|
||||
skillDir := filepath.Join(si.workspace, "skills", filepath.Base(repo))
|
||||
|
||||
if _, err := os.Stat(skillDir); err == nil {
|
||||
return fmt.Errorf("skill '%s' already exists", filepath.Base(repo))
|
||||
ref, err := parseGitHubRef(repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://raw.githubusercontent.com/%s/main/SKILL.md", repo)
|
||||
skillName := ref.RepoName
|
||||
if ref.SubPath != "" {
|
||||
skillName = filepath.Base(ref.SubPath)
|
||||
}
|
||||
skillDirectory := filepath.Join(si.workspace, "skills", skillName)
|
||||
|
||||
if _, err := os.Stat(skillDirectory); err == nil {
|
||||
return fmt.Errorf("skill '%s' already exists", skillName)
|
||||
}
|
||||
|
||||
// Build GitHub API URL
|
||||
apiPath := path.Join(ref.Owner, ref.RepoName, "contents")
|
||||
if ref.SubPath != "" {
|
||||
apiPath = path.Join(apiPath, ref.SubPath)
|
||||
}
|
||||
apiURL := fmt.Sprintf("https://api.github.com/repos/%s?ref=%s", apiPath, ref.Ref)
|
||||
|
||||
if err := si.getGithubDirAllFiles(ctx, apiURL, skillDirectory, true); err != nil {
|
||||
// Fallback to raw download
|
||||
return si.downloadRaw(ctx, ref.Owner, ref.RepoName, ref.Ref, ref.SubPath, skillDirectory)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(skillDirectory, "SKILL.md")); err != nil {
|
||||
return fmt.Errorf("SKILL.md not found in repository")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadDir recursively downloads a directory from GitHub API
|
||||
// isRoot: true if this is the skill root directory (only download SKILL.md at root)
|
||||
func (si *SkillInstaller) getGithubDirAllFiles(ctx context.Context, apiURL, localDir string, isRoot bool) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if si.githubToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+si.githubToken)
|
||||
}
|
||||
|
||||
resp, err := utils.DoRequestWithRetry(si.client, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var items []GitHubContent
|
||||
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
localPath := filepath.Join(localDir, item.Name)
|
||||
|
||||
switch item.Type {
|
||||
case "file":
|
||||
if !shouldDownload(item.Name, isRoot) {
|
||||
continue
|
||||
}
|
||||
if err := si.downloadFile(ctx, item.DownloadURL, localPath); err != nil {
|
||||
return fmt.Errorf("download %s: %w", item.Name, err)
|
||||
}
|
||||
case "dir":
|
||||
if !isSkillDirectory(item.Name) {
|
||||
continue
|
||||
}
|
||||
if err := si.getGithubDirAllFiles(ctx, item.URL, localPath, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadRaw is a fallback that downloads just SKILL.md from raw.githubusercontent.com
|
||||
func (si *SkillInstaller) downloadRaw(ctx context.Context, owner, repo, ref, subPath, localDir string) error {
|
||||
urlPath := path.Join(owner, repo, ref)
|
||||
if subPath != "" {
|
||||
urlPath = path.Join(urlPath, subPath)
|
||||
}
|
||||
url := fmt.Sprintf("https://raw.githubusercontent.com/%s/SKILL.md", urlPath)
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := utils.DoRequestWithRetry(client, req)
|
||||
// Use chunked download to temporary file.
|
||||
tmpPath, err := utils.DownloadToFile(ctx, si.client, req, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch skill: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("failed to fetch skill: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
if err := os.MkdirAll(localDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create skill directory: %w", err)
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||
localPath := filepath.Join(localDir, "SKILL.md")
|
||||
|
||||
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||
if err := fileutil.WriteFileAtomic(skillPath, body, 0o600); err != nil {
|
||||
// Atomic move from temp to final location.
|
||||
if err := os.Rename(tmpPath, localPath); err != nil {
|
||||
return fmt.Errorf("failed to write skill file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return os.Chmod(localPath, 0o600)
|
||||
}
|
||||
|
||||
func (si *SkillInstaller) downloadFile(ctx context.Context, url, localPath string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use chunked download to temporary file, then move atomically to target.
|
||||
tmpPath, err := utils.DownloadToFile(ctx, si.client, req, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Atomic move from temp to final location.
|
||||
if err := os.Rename(tmpPath, localPath); err != nil {
|
||||
return fmt.Errorf("failed to move downloaded file: %w", err)
|
||||
}
|
||||
|
||||
return os.Chmod(localPath, 0o600)
|
||||
}
|
||||
|
||||
// shouldDownload determines if a file should be downloaded
|
||||
// root: true if we're at the skill root directory
|
||||
func shouldDownload(name string, root bool) bool {
|
||||
if root {
|
||||
return name == "SKILL.md"
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isSkillDir checks if a directory is a standard skill resource directory
|
||||
func isSkillDirectory(name string) bool {
|
||||
switch name {
|
||||
case "scripts", "references", "assets", "templates", "docs":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (si *SkillInstaller) Uninstall(skillName string) error {
|
||||
skillDir := filepath.Join(si.workspace, "skills", skillName)
|
||||
parts := strings.Split(skillName, "/")
|
||||
var finalSkillName string
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
if parts[i] != "" {
|
||||
finalSkillName = parts[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if finalSkillName == "" {
|
||||
finalSkillName = skillName
|
||||
}
|
||||
|
||||
skillDir := filepath.Join(si.workspace, "skills", finalSkillName)
|
||||
|
||||
if _, err := os.Stat(skillDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("skill '%s' not found", skillName)
|
||||
return fmt.Errorf("skill '%s' not found (processed as '%s')", skillName, finalSkillName)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(skillDir); err != nil {
|
||||
return fmt.Errorf("failed to remove skill: %w", err)
|
||||
return fmt.Errorf("failed to remove skill '%s': %w", finalSkillName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
665
pkg/skills/installer_test.go
Normal file
665
pkg/skills/installer_test.go
Normal file
|
|
@ -0,0 +1,665 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseGitHubRef(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repo string
|
||||
wantOwner string
|
||||
wantRepoName string
|
||||
wantRef string
|
||||
wantSubPath string
|
||||
wantErr bool
|
||||
wantErrContain string
|
||||
}{
|
||||
{
|
||||
name: "simple owner/repo",
|
||||
repo: "sipeed/picoclaw",
|
||||
wantOwner: "sipeed",
|
||||
wantRepoName: "picoclaw",
|
||||
wantRef: "main",
|
||||
wantSubPath: "",
|
||||
},
|
||||
{
|
||||
name: "owner/repo with subpath",
|
||||
repo: "sipeed/picoclaw/skills/test",
|
||||
wantOwner: "sipeed",
|
||||
wantRepoName: "picoclaw",
|
||||
wantRef: "main",
|
||||
wantSubPath: "skills/test",
|
||||
},
|
||||
{
|
||||
name: "full URL with tree",
|
||||
repo: "https://github.com/sipeed/picoclaw/tree/dev/skills/test",
|
||||
wantOwner: "sipeed",
|
||||
wantRepoName: "picoclaw",
|
||||
wantRef: "dev",
|
||||
wantSubPath: "skills/test",
|
||||
},
|
||||
{
|
||||
name: "full URL with blob",
|
||||
repo: "https://github.com/sipeed/picoclaw/blob/main/README.md",
|
||||
wantOwner: "sipeed",
|
||||
wantRepoName: "picoclaw",
|
||||
wantRef: "main",
|
||||
wantSubPath: "README.md",
|
||||
},
|
||||
{
|
||||
name: "full URL without ref",
|
||||
repo: "https://github.com/sipeed/picoclaw",
|
||||
wantOwner: "sipeed",
|
||||
wantRepoName: "picoclaw",
|
||||
wantRef: "main",
|
||||
wantSubPath: "",
|
||||
},
|
||||
{
|
||||
name: "invalid format - single part",
|
||||
repo: "sipeed",
|
||||
wantErr: true,
|
||||
wantErrContain: "expected 'owner/repo'",
|
||||
},
|
||||
{
|
||||
name: "invalid URL",
|
||||
repo: "http://[invalid",
|
||||
wantErr: true,
|
||||
wantErrContain: "invalid URL",
|
||||
},
|
||||
{
|
||||
name: "invalid GitHub URL - only one path part",
|
||||
repo: "https://github.com/sipeed",
|
||||
wantErr: true,
|
||||
wantErrContain: "invalid GitHub URL",
|
||||
},
|
||||
{
|
||||
name: "with whitespace",
|
||||
repo: " sipeed/picoclaw ",
|
||||
wantOwner: "sipeed",
|
||||
wantRepoName: "picoclaw",
|
||||
wantRef: "main",
|
||||
wantSubPath: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ref, err := parseGitHubRef(tt.repo)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("parseGitHubRef() error = nil, wantErr = true")
|
||||
return
|
||||
}
|
||||
if tt.wantErrContain != "" && !strings.Contains(err.Error(), tt.wantErrContain) {
|
||||
t.Errorf("parseGitHubRef() error = %v, want error containing %v", err, tt.wantErrContain)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("parseGitHubRef() unexpected error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ref.Owner != tt.wantOwner {
|
||||
t.Errorf("parseGitHubRef() owner = %v, want %v", ref.Owner, tt.wantOwner)
|
||||
}
|
||||
if ref.RepoName != tt.wantRepoName {
|
||||
t.Errorf("parseGitHubRef() repoName = %v, want %v", ref.RepoName, tt.wantRepoName)
|
||||
}
|
||||
if ref.Ref != tt.wantRef {
|
||||
t.Errorf("parseGitHubRef() ref = %v, want %v", ref.Ref, tt.wantRef)
|
||||
}
|
||||
if ref.SubPath != tt.wantSubPath {
|
||||
t.Errorf("parseGitHubRef() subPath = %v, want %v", ref.SubPath, tt.wantSubPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldDownload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
root bool
|
||||
want bool
|
||||
}{
|
||||
{"SKILL.md at root", "SKILL.md", true, true},
|
||||
{"other file at root", "README.md", true, false},
|
||||
{"script at root", "script.py", true, false},
|
||||
{"SKILL.md not at root", "SKILL.md", false, true},
|
||||
{"any file not at root", "any.txt", false, true},
|
||||
{"script not at root", "script.py", false, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldDownload(tt.file, tt.root)
|
||||
if got != tt.want {
|
||||
t.Errorf("shouldDownload(%q, %v) = %v, want %v", tt.file, tt.root, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSkillDirectory(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dir string
|
||||
want bool
|
||||
}{
|
||||
{"scripts dir", "scripts", true},
|
||||
{"references dir", "references", true},
|
||||
{"assets dir", "assets", true},
|
||||
{"templates dir", "templates", true},
|
||||
{"docs dir", "docs", true},
|
||||
{"other dir", "other", false},
|
||||
{"src dir", "src", false},
|
||||
{"empty string", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isSkillDirectory(tt.dir)
|
||||
if got != tt.want {
|
||||
t.Errorf("isSkillDirectory(%q) = %v, want %v", tt.dir, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSkillInstaller(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
installer, err := NewSkillInstaller(tmpDir, "test-token", "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSkillInstaller() error = %v", err)
|
||||
}
|
||||
|
||||
if installer == nil {
|
||||
t.Fatal("NewSkillInstaller() returned nil")
|
||||
}
|
||||
|
||||
if installer.workspace != tmpDir {
|
||||
t.Errorf("workspace = %v, want %v", installer.workspace, tmpDir)
|
||||
}
|
||||
|
||||
if installer.githubToken != "test-token" {
|
||||
t.Errorf("githubToken = %v, want 'test-token'", installer.githubToken)
|
||||
}
|
||||
|
||||
if installer.proxy != "" {
|
||||
t.Errorf("proxy = %v, want empty", installer.proxy)
|
||||
}
|
||||
|
||||
if installer.client == nil {
|
||||
t.Error("client is nil")
|
||||
} else if installer.client.Timeout != 15*time.Second {
|
||||
t.Errorf("client.Timeout = %v, want 15s", installer.client.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSkillInstaller_WithProxy(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
installer, err := NewSkillInstaller(tmpDir, "test-token", "http://127.0.0.1:7890")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSkillInstaller() error = %v", err)
|
||||
}
|
||||
|
||||
if installer.proxy != "http://127.0.0.1:7890" {
|
||||
t.Errorf("proxy = %v, want 'http://127.0.0.1:7890'", installer.proxy)
|
||||
}
|
||||
|
||||
if installer.client == nil {
|
||||
t.Fatal("client is nil")
|
||||
}
|
||||
|
||||
// Verify the transport has proxy configured
|
||||
transport, ok := installer.client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatal("client.Transport is not *http.Transport")
|
||||
}
|
||||
|
||||
if transport.Proxy == nil {
|
||||
t.Error("transport.Proxy is nil, expected non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSkillInstaller_InvalidProxy(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
installer, err := NewSkillInstaller(tmpDir, "test-token", "://invalid-proxy")
|
||||
if err == nil {
|
||||
t.Error("NewSkillInstaller() expected error for invalid proxy, got nil")
|
||||
}
|
||||
if installer != nil {
|
||||
t.Error("expected nil installer on error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillInstaller_DownloadFile(t *testing.T) {
|
||||
// Create a test server that serves files
|
||||
content := "test file content for skill download"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("expected GET, got %s", r.Method)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(content))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
installer, err := NewSkillInstaller(tmpDir, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSkillInstaller() error = %v", err)
|
||||
}
|
||||
|
||||
t.Run("successful download", func(t *testing.T) {
|
||||
localPath := filepath.Join(tmpDir, "test-skill", "SKILL.md")
|
||||
err := installer.downloadFile(context.Background(), server.URL, localPath)
|
||||
if err != nil {
|
||||
t.Errorf("downloadFile() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify file was downloaded
|
||||
data, err := os.ReadFile(localPath)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read downloaded file: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if string(data) != content {
|
||||
t.Errorf("downloaded content = %q, want %q", string(data), content)
|
||||
}
|
||||
|
||||
// Check file permissions
|
||||
info, err := os.Stat(localPath)
|
||||
if err != nil {
|
||||
t.Errorf("failed to stat file: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("file permissions = %o, want %o", info.Mode().Perm(), 0o600)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("http error", func(t *testing.T) {
|
||||
errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("not found"))
|
||||
}))
|
||||
defer errorServer.Close()
|
||||
|
||||
localPath := filepath.Join(tmpDir, "error-test", "SKILL.md")
|
||||
err := installer.downloadFile(context.Background(), errorServer.URL, localPath)
|
||||
if err == nil {
|
||||
t.Error("downloadFile() expected error for 404, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkillInstaller_DownloadRaw(t *testing.T) {
|
||||
content := "raw skill content"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(content))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
installer, err := NewSkillInstaller(tmpDir, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSkillInstaller() error = %v", err)
|
||||
}
|
||||
|
||||
// Replace the client with one that points to our test server
|
||||
// We need to modify the URL in the function, so we'll test indirectly
|
||||
|
||||
localDir := filepath.Join(tmpDir, "raw-test")
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a simple test by calling downloadFile directly since downloadRaw
|
||||
// constructs its own URL
|
||||
testFile := filepath.Join(localDir, "SKILL.md")
|
||||
err = installer.downloadFile(ctx, server.URL, testFile)
|
||||
if err != nil {
|
||||
t.Errorf("downloadFile() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify file content
|
||||
data, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read file: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if string(data) != content {
|
||||
t.Errorf("content = %q, want %q", string(data), content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillInstaller_Uninstall(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
skillsDir := filepath.Join(tmpDir, "skills")
|
||||
os.MkdirAll(skillsDir, 0o755)
|
||||
|
||||
installer, err := NewSkillInstaller(tmpDir, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSkillInstaller() error = %v", err)
|
||||
}
|
||||
|
||||
t.Run("uninstall existing skill", func(t *testing.T) {
|
||||
skillName := "test-skill"
|
||||
skillDir := filepath.Join(skillsDir, skillName)
|
||||
|
||||
// Create skill directory with a file
|
||||
os.MkdirAll(skillDir, 0o755)
|
||||
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("test"), 0o644)
|
||||
|
||||
if err := installer.Uninstall(skillName); err != nil {
|
||||
t.Errorf("Uninstall() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify directory was removed
|
||||
if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
|
||||
t.Error("skill directory still exists after uninstall")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uninstall non-existent skill", func(t *testing.T) {
|
||||
if err := installer.Uninstall("non-existent-skill"); err == nil {
|
||||
t.Error("Uninstall() expected error for non-existent skill, got nil")
|
||||
} else if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("error message = %q, want 'not found'", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uninstall with path separator", func(t *testing.T) {
|
||||
skillName := "owner/repo/skill-name"
|
||||
skillDir := filepath.Join(skillsDir, "skill-name")
|
||||
|
||||
// Create skill directory
|
||||
os.MkdirAll(skillDir, 0o755)
|
||||
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("test"), 0o644)
|
||||
|
||||
if err := installer.Uninstall(skillName); err != nil {
|
||||
t.Errorf("Uninstall() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
|
||||
t.Error("skill directory still exists after uninstall")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uninstall with trailing slash", func(t *testing.T) {
|
||||
skillName := "skill-name/"
|
||||
skillDir := filepath.Join(skillsDir, "skill-name")
|
||||
|
||||
// Create skill directory
|
||||
os.MkdirAll(skillDir, 0o755)
|
||||
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("test"), 0o644)
|
||||
|
||||
if err := installer.Uninstall(skillName); err != nil {
|
||||
t.Errorf("Uninstall() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
|
||||
t.Error("skill directory still exists after uninstall")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkillInstaller_InstallFromGitHub_SkillAlreadyExists(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
skillsDir := filepath.Join(tmpDir, "skills")
|
||||
os.MkdirAll(skillsDir, 0o755)
|
||||
|
||||
installer, err := NewSkillInstaller(tmpDir, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSkillInstaller() error = %v", err)
|
||||
}
|
||||
|
||||
// Create an existing skill directory
|
||||
existingSkill := filepath.Join(skillsDir, "picoclaw")
|
||||
os.MkdirAll(existingSkill, 0o755)
|
||||
os.WriteFile(filepath.Join(existingSkill, "SKILL.md"), []byte("existing"), 0o644)
|
||||
|
||||
// Try to install the same skill - should fail
|
||||
err = installer.InstallFromGitHub(context.Background(), "sipeed/picoclaw")
|
||||
if err == nil {
|
||||
t.Error("InstallFromGitHub() expected error for existing skill, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already exists") {
|
||||
t.Errorf("error message = %q, want 'already exists'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubContent_Struct(t *testing.T) {
|
||||
// Test that GitHubContent struct can be properly unmarshaled
|
||||
jsonData := `{
|
||||
"name": "test.md",
|
||||
"path": "skills/test.md",
|
||||
"type": "file",
|
||||
"download_url": "https://example.com/download",
|
||||
"url": "https://api.github.com/contents/skills/test.md"
|
||||
}`
|
||||
|
||||
var content GitHubContent
|
||||
err := json.Unmarshal([]byte(jsonData), &content)
|
||||
if err != nil {
|
||||
t.Errorf("failed to unmarshal GitHubContent: %v", err)
|
||||
}
|
||||
|
||||
if content.Name != "test.md" {
|
||||
t.Errorf("Name = %q, want 'test.md'", content.Name)
|
||||
}
|
||||
if content.Type != "file" {
|
||||
t.Errorf("Type = %q, want 'file'", content.Type)
|
||||
}
|
||||
if content.DownloadURL != "https://example.com/download" {
|
||||
t.Errorf("DownloadURL = %q, want 'https://example.com/download'", content.DownloadURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillInstaller_GetGithubDirAllFiles(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
installer, err := NewSkillInstaller(tmpDir, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSkillInstaller() error = %v", err)
|
||||
}
|
||||
|
||||
// Create a test server that mimics GitHub API
|
||||
fileContent := "skill file content"
|
||||
var serverURL string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check for authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader != "" && !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
t.Errorf("expected Bearer token, got: %s", authHeader)
|
||||
}
|
||||
|
||||
// Return different responses based on path
|
||||
if strings.Contains(r.URL.Path, "/contents") {
|
||||
// API response for directory listing
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
items := []map[string]any{
|
||||
{
|
||||
"name": "SKILL.md",
|
||||
"path": "SKILL.md",
|
||||
"type": "file",
|
||||
"download_url": serverURL + "/download/SKILL.md",
|
||||
},
|
||||
{
|
||||
"name": "scripts",
|
||||
"path": "scripts",
|
||||
"type": "dir",
|
||||
"url": serverURL + "/api/scripts",
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(items)
|
||||
} else if strings.Contains(r.URL.Path, "/api/scripts") {
|
||||
// API response for scripts subdirectory
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
items := []map[string]any{
|
||||
{
|
||||
"name": "test.py",
|
||||
"path": "scripts/test.py",
|
||||
"type": "file",
|
||||
"download_url": serverURL + "/download/test.py",
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(items)
|
||||
} else if strings.Contains(r.URL.Path, "/download/") {
|
||||
// Raw file download
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(fileContent))
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
serverURL = server.URL
|
||||
defer server.Close()
|
||||
|
||||
localDir := filepath.Join(tmpDir, "test-skill")
|
||||
|
||||
t.Run("download from GitHub API", func(t *testing.T) {
|
||||
err := installer.getGithubDirAllFiles(context.Background(), server.URL+"/contents", localDir, true)
|
||||
if err != nil {
|
||||
t.Errorf("getGithubDirAllFiles() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify SKILL.md was downloaded
|
||||
skillMd := filepath.Join(localDir, "SKILL.md")
|
||||
data, err := os.ReadFile(skillMd)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read SKILL.md: %v", err)
|
||||
return
|
||||
}
|
||||
if string(data) != fileContent {
|
||||
t.Errorf("SKILL.md content = %q, want %q", string(data), fileContent)
|
||||
}
|
||||
|
||||
// Verify scripts directory and file
|
||||
scriptFile := filepath.Join(localDir, "scripts", "test.py")
|
||||
data, err = os.ReadFile(scriptFile)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read test.py: %v", err)
|
||||
return
|
||||
}
|
||||
if string(data) != fileContent {
|
||||
t.Errorf("test.py content = %q, want %q", string(data), fileContent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("http error response", func(t *testing.T) {
|
||||
errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer errorServer.Close()
|
||||
|
||||
err := installer.getGithubDirAllFiles(
|
||||
context.Background(),
|
||||
errorServer.URL,
|
||||
filepath.Join(tmpDir, "error-test"),
|
||||
true,
|
||||
)
|
||||
if err == nil {
|
||||
t.Error("getGithubDirAllFiles() expected error for 403, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkillInstaller_InstallFromGitHub_WithToken(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
skillsDir := filepath.Join(tmpDir, "skills")
|
||||
os.MkdirAll(skillsDir, 0o755)
|
||||
|
||||
var serverURL string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Capture the authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader != "" {
|
||||
tokenReceived := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
t.Fatalf("github token is %s", tokenReceived)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
items := []map[string]any{
|
||||
{
|
||||
"name": "SKILL.md",
|
||||
"path": "SKILL.md",
|
||||
"type": "file",
|
||||
"download_url": serverURL + "/download/SKILL.md",
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(items)
|
||||
}))
|
||||
serverURL = server.URL
|
||||
defer server.Close()
|
||||
|
||||
installer, err := NewSkillInstaller(tmpDir, "test-github-token", "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSkillInstaller() error = %v", err)
|
||||
}
|
||||
|
||||
// We need to test the token is passed - the actual install will fail
|
||||
// because we're not fully mocking the download, but we can verify
|
||||
// the token is sent in the request
|
||||
|
||||
// Use a simple context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// The install will fail because download URL isn't properly set up,
|
||||
// but the token should be sent in the API request
|
||||
_ = installer.InstallFromGitHub(ctx, "owner/repo")
|
||||
|
||||
// Note: We can't easily intercept the download request since it's a different URL,
|
||||
// but the fact that the API request was made verifies the token flow
|
||||
// In a real scenario, the token would be sent to both API and raw downloads
|
||||
}
|
||||
|
||||
func TestSkillInstaller_ContextCancellation(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
installer, err := NewSkillInstaller(tmpDir, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSkillInstaller() error = %v", err)
|
||||
}
|
||||
|
||||
// Create a slow server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("response"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create a canceled context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
localPath := filepath.Join(tmpDir, "cancel-test", "file.txt")
|
||||
err = installer.downloadFile(ctx, server.URL, localPath)
|
||||
|
||||
if err == nil {
|
||||
t.Error("downloadFile() expected error for canceled context, got nil")
|
||||
}
|
||||
}
|
||||
|
|
@ -373,9 +373,37 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
matches := absolutePathPattern.FindAllString(cmd, -1)
|
||||
// Web URL schemes whose path components (starting with //) should be exempt
|
||||
// from workspace sandbox checks. file: is intentionally excluded so that
|
||||
// file:// URIs are still validated against the workspace boundary.
|
||||
webSchemes := []string{"http:", "https:", "ftp:", "ftps:", "sftp:", "ssh:", "git:"}
|
||||
|
||||
matchIndices := absolutePathPattern.FindAllStringIndex(cmd, -1)
|
||||
|
||||
for _, loc := range matchIndices {
|
||||
raw := cmd[loc[0]:loc[1]]
|
||||
|
||||
// Skip URL path components that look like they're from web URLs.
|
||||
// When a URL like "https://github.com" is parsed, the regex captures
|
||||
// "//github.com" as a match (the path portion after "https:").
|
||||
// Use the exact match position (loc[0]) so that duplicate //path substrings
|
||||
// in the same command are each evaluated at their own position.
|
||||
if strings.HasPrefix(raw, "//") && loc[0] > 0 {
|
||||
before := cmd[:loc[0]]
|
||||
isWebURL := false
|
||||
|
||||
for _, scheme := range webSchemes {
|
||||
if strings.HasSuffix(before, scheme) {
|
||||
isWebURL = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isWebURL {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range matches {
|
||||
p, err := filepath.Abs(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -522,3 +522,101 @@ func TestShellTool_CustomAllowPatterns(t *testing.T) {
|
|||
t.Errorf("'git push upstream main' should still be blocked by deny pattern")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShellTool_URLsNotBlocked verifies that commands containing URLs are not
|
||||
// incorrectly blocked by the workspace restriction safety guard (issue #1203).
|
||||
func TestShellTool_URLsNotBlocked(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
tool, err := NewExecTool(tmpDir, true)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to configure exec tool: %s", err)
|
||||
}
|
||||
|
||||
// These commands contain URLs and should NOT be blocked by workspace restriction.
|
||||
// The URL path components (e.g., "//github.com") should be recognized as URLs,
|
||||
// not as file system paths.
|
||||
commands := []string{
|
||||
"agent-browser open https://github.com",
|
||||
"curl https://api.example.com/data",
|
||||
"wget http://example.com/file",
|
||||
"browser open https://github.com/user/repo",
|
||||
"fetch ftp://ftp.example.com/file.txt",
|
||||
"git clone https://github.com/sipeed/picoclaw.git",
|
||||
}
|
||||
|
||||
for _, cmd := range commands {
|
||||
result := tool.Execute(context.Background(), map[string]any{"command": cmd})
|
||||
if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") {
|
||||
t.Errorf("command with URL should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShellTool_FileURISandboxing verifies that file:// URIs that escape the
|
||||
// workspace are still blocked, even though other URLs are allowed (issue #1254).
|
||||
func TestShellTool_FileURISandboxing(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
tool, err := NewExecTool(tmpDir, true)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to configure exec tool: %s", err)
|
||||
}
|
||||
|
||||
// These file:// URIs should be blocked if they reference paths outside the workspace.
|
||||
// Unlike web URLs (http://, https://, ftp://), file:// URIs can be used to escape the sandbox.
|
||||
blockedCommands := []string{
|
||||
"cat file:///etc/passwd",
|
||||
"cat file:///etc/hosts",
|
||||
"cat file:///root/.ssh/id_rsa",
|
||||
}
|
||||
|
||||
for _, cmd := range blockedCommands {
|
||||
result := tool.Execute(context.Background(), map[string]any{"command": cmd})
|
||||
if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") {
|
||||
t.Errorf("file:// URI outside workspace should be blocked: %s", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// These file:// URIs should be allowed if they reference paths inside the workspace.
|
||||
// Create a test file inside the temp directory
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create test file: %s", err)
|
||||
}
|
||||
|
||||
allowedCommands := []string{
|
||||
"cat file://" + testFile,
|
||||
}
|
||||
|
||||
for _, cmd := range allowedCommands {
|
||||
result := tool.Execute(context.Background(), map[string]any{"command": cmd})
|
||||
if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") {
|
||||
t.Errorf("file:// URI inside workspace should be allowed: %s\n error: %s", cmd, result.ForLLM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShellTool_URLBypassPrevented verifies that a command cannot bypass the workspace
|
||||
// sandbox by smuggling a real path after a URL that contains the same //path substring.
|
||||
// e.g. "echo https://etc/passwd && cat //etc/passwd" must still be blocked.
|
||||
func TestShellTool_URLBypassPrevented(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
tool, err := NewExecTool(tmpDir, true)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to configure exec tool: %s", err)
|
||||
}
|
||||
|
||||
// The path //etc/passwd appears twice: once as the host part of an https URL
|
||||
// and once as a real (escaped) absolute path. The guard must block the command
|
||||
// because the second occurrence is a genuine out-of-workspace path.
|
||||
blockedCommands := []string{
|
||||
"echo https://etc/passwd && cat //etc/passwd",
|
||||
"curl https://host/file && ls //etc",
|
||||
}
|
||||
|
||||
for _, cmd := range blockedCommands {
|
||||
result := tool.Execute(context.Background(), map[string]any{"command": cmd})
|
||||
if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") {
|
||||
t.Errorf("bypass attempt should be blocked: %q\n got: %s", cmd, result.ForLLM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import (
|
|||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -41,43 +43,6 @@ var (
|
|||
reDDGSnippet = regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
|
||||
)
|
||||
|
||||
// createHTTPClient creates an HTTP client with optional proxy support
|
||||
func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableCompression: false,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
if proxyURL != "" {
|
||||
proxy, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid proxy URL: %w", err)
|
||||
}
|
||||
scheme := strings.ToLower(proxy.Scheme)
|
||||
switch scheme {
|
||||
case "http", "https", "socks5", "socks5h":
|
||||
default:
|
||||
return nil, fmt.Errorf(
|
||||
"unsupported proxy scheme %q (supported: http, https, socks5, socks5h)",
|
||||
proxy.Scheme,
|
||||
)
|
||||
}
|
||||
if proxy.Host == "" {
|
||||
return nil, fmt.Errorf("invalid proxy URL: missing host")
|
||||
}
|
||||
client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy)
|
||||
} else {
|
||||
client.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
type APIKeyPool struct {
|
||||
keys []string
|
||||
current uint32
|
||||
|
|
@ -678,7 +643,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
|||
maxResults := 5
|
||||
// Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search
|
||||
if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 {
|
||||
client, err := createHTTPClient(opts.Proxy, perplexityTimeout)
|
||||
client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err)
|
||||
}
|
||||
|
|
@ -691,7 +656,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
|||
maxResults = opts.PerplexityMaxResults
|
||||
}
|
||||
} else if opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 {
|
||||
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
||||
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err)
|
||||
}
|
||||
|
|
@ -705,7 +670,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
|||
maxResults = opts.SearXNGMaxResults
|
||||
}
|
||||
} else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 {
|
||||
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
||||
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err)
|
||||
}
|
||||
|
|
@ -719,7 +684,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
|||
maxResults = opts.TavilyMaxResults
|
||||
}
|
||||
} else if opts.DuckDuckGoEnabled {
|
||||
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
||||
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err)
|
||||
}
|
||||
|
|
@ -728,7 +693,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
|||
maxResults = opts.DuckDuckGoMaxResults
|
||||
}
|
||||
} else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" {
|
||||
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
||||
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err)
|
||||
}
|
||||
|
|
@ -827,7 +792,7 @@ func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64)
|
|||
if maxChars <= 0 {
|
||||
maxChars = defaultMaxChars
|
||||
}
|
||||
client, err := createHTTPClient(proxy, fetchTimeout)
|
||||
client, err := utils.CreateHTTPClient(proxy, fetchTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import (
|
|||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
|
@ -639,108 +638,6 @@ func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCreateHTTPClient_ProxyConfigured(t *testing.T) {
|
||||
client, err := createHTTPClient("http://127.0.0.1:7890", 12*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("createHTTPClient() error: %v", err)
|
||||
}
|
||||
if client.Timeout != 12*time.Second {
|
||||
t.Fatalf("client.Timeout = %v, want %v", client.Timeout, 12*time.Second)
|
||||
}
|
||||
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("transport.Proxy is nil, want non-nil")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest() error: %v", err)
|
||||
}
|
||||
proxyURL, err := tr.Proxy(req)
|
||||
if err != nil {
|
||||
t.Fatalf("transport.Proxy(req) error: %v", err)
|
||||
}
|
||||
if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" {
|
||||
t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateHTTPClient_InvalidProxy(t *testing.T) {
|
||||
_, err := createHTTPClient("://bad-proxy", 10*time.Second)
|
||||
if err == nil {
|
||||
t.Fatal("createHTTPClient() expected error for invalid proxy URL, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) {
|
||||
client, err := createHTTPClient("socks5://127.0.0.1:1080", 8*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("createHTTPClient() error: %v", err)
|
||||
}
|
||||
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
req, err := http.NewRequest("GET", "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest() error: %v", err)
|
||||
}
|
||||
proxyURL, err := tr.Proxy(req)
|
||||
if err != nil {
|
||||
t.Fatalf("transport.Proxy(req) error: %v", err)
|
||||
}
|
||||
if proxyURL == nil || proxyURL.String() != "socks5://127.0.0.1:1080" {
|
||||
t.Fatalf("proxy URL = %v, want %q", proxyURL, "socks5://127.0.0.1:1080")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateHTTPClient_UnsupportedProxyScheme(t *testing.T) {
|
||||
_, err := createHTTPClient("ftp://127.0.0.1:21", 10*time.Second)
|
||||
if err == nil {
|
||||
t.Fatal("createHTTPClient() expected error for unsupported scheme, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported proxy scheme") {
|
||||
t.Fatalf("error = %q, want to contain %q", err.Error(), "unsupported proxy scheme")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) {
|
||||
t.Setenv("HTTP_PROXY", "http://127.0.0.1:8888")
|
||||
t.Setenv("http_proxy", "http://127.0.0.1:8888")
|
||||
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888")
|
||||
t.Setenv("https_proxy", "http://127.0.0.1:8888")
|
||||
t.Setenv("ALL_PROXY", "")
|
||||
t.Setenv("all_proxy", "")
|
||||
t.Setenv("NO_PROXY", "")
|
||||
t.Setenv("no_proxy", "")
|
||||
|
||||
client, err := createHTTPClient("", 10*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("createHTTPClient() error: %v", err)
|
||||
}
|
||||
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("transport.Proxy is nil, want proxy function from environment")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest() error: %v", err)
|
||||
}
|
||||
if _, err := tr.Proxy(req); err != nil {
|
||||
t.Fatalf("transport.Proxy(req) error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWebFetchToolWithProxy(t *testing.T) {
|
||||
tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890", testFetchLimit)
|
||||
if err != nil {
|
||||
|
|
|
|||
48
pkg/utils/http_client.go
Normal file
48
pkg/utils/http_client.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreateHTTPClient creates an HTTP client with optional proxy support.
|
||||
// If proxyURL is empty, it uses the system environment proxy settings.
|
||||
// Supported proxy schemes: http, https, socks5, socks5h.
|
||||
func CreateHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableCompression: false,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
if proxyURL != "" {
|
||||
proxy, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid proxy URL: %w", err)
|
||||
}
|
||||
scheme := strings.ToLower(proxy.Scheme)
|
||||
switch scheme {
|
||||
case "http", "https", "socks5", "socks5h":
|
||||
default:
|
||||
return nil, fmt.Errorf(
|
||||
"unsupported proxy scheme %q (supported: http, https, socks5, socks5h)",
|
||||
proxy.Scheme,
|
||||
)
|
||||
}
|
||||
if proxy.Host == "" {
|
||||
return nil, fmt.Errorf("invalid proxy URL: missing host")
|
||||
}
|
||||
client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy)
|
||||
} else {
|
||||
client.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
110
pkg/utils/http_client_test.go
Normal file
110
pkg/utils/http_client_test.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreateHTTPClient_ProxyConfigured(t *testing.T) {
|
||||
client, err := CreateHTTPClient("http://127.0.0.1:7890", 12*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("createHTTPClient() error: %v", err)
|
||||
}
|
||||
if client.Timeout != 12*time.Second {
|
||||
t.Fatalf("client.Timeout = %v, want %v", client.Timeout, 12*time.Second)
|
||||
}
|
||||
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("transport.Proxy is nil, want non-nil")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest() error: %v", err)
|
||||
}
|
||||
proxyURL, err := tr.Proxy(req)
|
||||
if err != nil {
|
||||
t.Fatalf("transport.Proxy(req) error: %v", err)
|
||||
}
|
||||
if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" {
|
||||
t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateHTTPClient_InvalidProxy(t *testing.T) {
|
||||
_, err := CreateHTTPClient("://bad-proxy", 10*time.Second)
|
||||
if err == nil {
|
||||
t.Fatal("createHTTPClient() expected error for invalid proxy URL, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) {
|
||||
client, err := CreateHTTPClient("socks5://127.0.0.1:1080", 8*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("createHTTPClient() error: %v", err)
|
||||
}
|
||||
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
req, err := http.NewRequest("GET", "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest() error: %v", err)
|
||||
}
|
||||
proxyURL, err := tr.Proxy(req)
|
||||
if err != nil {
|
||||
t.Fatalf("transport.Proxy(req) error: %v", err)
|
||||
}
|
||||
if proxyURL == nil || proxyURL.String() != "socks5://127.0.0.1:1080" {
|
||||
t.Fatalf("proxy URL = %v, want %q", proxyURL, "socks5://127.0.0.1:1080")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateHTTPClient_UnsupportedProxyScheme(t *testing.T) {
|
||||
_, err := CreateHTTPClient("ftp://127.0.0.1:21", 10*time.Second)
|
||||
if err == nil {
|
||||
t.Fatal("createHTTPClient() expected error for unsupported scheme, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported proxy scheme") {
|
||||
t.Fatalf("error = %q, want to contain %q", err.Error(), "unsupported proxy scheme")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) {
|
||||
t.Setenv("HTTP_PROXY", "http://127.0.0.1:8888")
|
||||
t.Setenv("http_proxy", "http://127.0.0.1:8888")
|
||||
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888")
|
||||
t.Setenv("https_proxy", "http://127.0.0.1:8888")
|
||||
t.Setenv("ALL_PROXY", "")
|
||||
t.Setenv("all_proxy", "")
|
||||
t.Setenv("NO_PROXY", "")
|
||||
t.Setenv("no_proxy", "")
|
||||
|
||||
client, err := CreateHTTPClient("", 10*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("createHTTPClient() error: %v", err)
|
||||
}
|
||||
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("transport.Proxy is nil, want proxy function from environment")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest() error: %v", err)
|
||||
}
|
||||
if _, err := tr.Proxy(req); err != nil {
|
||||
t.Fatalf("transport.Proxy(req) error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,8 +7,11 @@ import (
|
|||
|
||||
// GatewayEvent represents a state change event for the gateway process.
|
||||
type GatewayEvent struct {
|
||||
Status string `json:"gateway_status"` // "running", "starting", "stopped", "error"
|
||||
Status string `json:"gateway_status"` // "running", "starting", "restarting", "stopped", "error"
|
||||
PID int `json:"pid,omitempty"`
|
||||
BootDefaultModel string `json:"boot_default_model,omitempty"`
|
||||
ConfigDefaultModel string `json:"config_default_model,omitempty"`
|
||||
RestartRequired bool `json:"gateway_restart_required,omitempty"`
|
||||
}
|
||||
|
||||
// EventBroadcaster manages SSE client subscriptions and broadcasts events.
|
||||
|
|
|
|||
|
|
@ -25,17 +25,34 @@ import (
|
|||
var gateway = struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
bootDefaultModel string
|
||||
runtimeStatus string
|
||||
startupDeadline time.Time
|
||||
logs *LogBuffer
|
||||
events *EventBroadcaster
|
||||
}{
|
||||
runtimeStatus: "stopped",
|
||||
logs: NewLogBuffer(200),
|
||||
events: NewEventBroadcaster(),
|
||||
}
|
||||
|
||||
var (
|
||||
gatewayStartupWindow = 15 * time.Second
|
||||
gatewayRestartGracePeriod = 5 * time.Second
|
||||
gatewayRestartForceKillWindow = 3 * time.Second
|
||||
gatewayRestartPollInterval = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) {
|
||||
client := http.Client{Timeout: timeout}
|
||||
return client.Get(url)
|
||||
}
|
||||
|
||||
// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux.
|
||||
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
|
||||
mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents)
|
||||
mux.HandleFunc("GET /api/gateway/logs", h.handleGatewayLogs)
|
||||
mux.HandleFunc("POST /api/gateway/logs/clear", h.handleGatewayClearLogs)
|
||||
mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart)
|
||||
mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop)
|
||||
|
|
@ -65,7 +82,7 @@ func (h *Handler) TryAutoStartGateway() {
|
|||
return
|
||||
}
|
||||
|
||||
pid, err := h.startGatewayLocked()
|
||||
pid, err := h.startGatewayLocked("starting")
|
||||
if err != nil {
|
||||
log.Printf("Failed to auto-start gateway: %v", err)
|
||||
return
|
||||
|
|
@ -131,7 +148,110 @@ func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
|
|||
return cmd.Process.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
func (h *Handler) startGatewayLocked() (int, error) {
|
||||
func setGatewayRuntimeStatusLocked(status string) {
|
||||
gateway.runtimeStatus = status
|
||||
if status == "starting" || status == "restarting" {
|
||||
gateway.startupDeadline = time.Now().Add(gatewayStartupWindow)
|
||||
return
|
||||
}
|
||||
gateway.startupDeadline = time.Time{}
|
||||
}
|
||||
|
||||
func gatewayStatusOnHealthFailureLocked() string {
|
||||
if gateway.runtimeStatus == "starting" || gateway.runtimeStatus == "restarting" {
|
||||
if gateway.startupDeadline.IsZero() || time.Now().Before(gateway.startupDeadline) {
|
||||
return gateway.runtimeStatus
|
||||
}
|
||||
return "error"
|
||||
}
|
||||
if gateway.runtimeStatus == "running" {
|
||||
return "running"
|
||||
}
|
||||
if gateway.runtimeStatus == "error" {
|
||||
return "error"
|
||||
}
|
||||
return "error"
|
||||
}
|
||||
|
||||
func currentGatewayStatusLocked(processAlive bool) string {
|
||||
if !processAlive {
|
||||
if gateway.runtimeStatus == "restarting" {
|
||||
if gateway.startupDeadline.IsZero() || time.Now().Before(gateway.startupDeadline) {
|
||||
return "restarting"
|
||||
}
|
||||
return "error"
|
||||
}
|
||||
if gateway.runtimeStatus == "error" {
|
||||
return "error"
|
||||
}
|
||||
return "stopped"
|
||||
}
|
||||
return gatewayStatusOnHealthFailureLocked()
|
||||
}
|
||||
|
||||
func waitForGatewayProcessExit(cmd *exec.Cmd, timeout time.Duration) bool {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if !isCmdProcessAliveLocked(cmd) {
|
||||
return true
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
time.Sleep(gatewayRestartPollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func stopGatewayProcessForRestart(cmd *exec.Cmd) error {
|
||||
if cmd == nil || cmd.Process == nil || !isCmdProcessAliveLocked(cmd) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var stopErr error
|
||||
if runtime.GOOS == "windows" {
|
||||
stopErr = cmd.Process.Kill()
|
||||
} else {
|
||||
stopErr = cmd.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
if stopErr != nil && isCmdProcessAliveLocked(cmd) {
|
||||
return fmt.Errorf("failed to stop existing gateway: %w", stopErr)
|
||||
}
|
||||
|
||||
if waitForGatewayProcessExit(cmd, gatewayRestartGracePeriod) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
killErr := cmd.Process.Signal(syscall.SIGKILL)
|
||||
if killErr != nil && isCmdProcessAliveLocked(cmd) {
|
||||
return fmt.Errorf("failed to force-stop existing gateway: %w", killErr)
|
||||
}
|
||||
if waitForGatewayProcessExit(cmd, gatewayRestartForceKillWindow) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("existing gateway did not exit before restart")
|
||||
}
|
||||
|
||||
func gatewayRestartRequired(status, bootDefaultModel, configDefaultModel string) bool {
|
||||
return status == "running" &&
|
||||
bootDefaultModel != "" &&
|
||||
configDefaultModel != "" &&
|
||||
bootDefaultModel != configDefaultModel
|
||||
}
|
||||
|
||||
func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
|
||||
|
||||
// Locate the picoclaw executable
|
||||
execPath := utils.FindPicoclawBinary()
|
||||
|
||||
|
|
@ -161,7 +281,7 @@ func (h *Handler) startGatewayLocked() (int, error) {
|
|||
gateway.logs.Reset()
|
||||
|
||||
// Ensure Pico Channel is configured before starting gateway
|
||||
if _, err := h.ensurePicoChannel(); err != nil {
|
||||
if _, err := h.ensurePicoChannel(""); err != nil {
|
||||
log.Printf("Warning: failed to ensure pico channel: %v", err)
|
||||
// Non-fatal: gateway can still start without pico channel
|
||||
}
|
||||
|
|
@ -171,11 +291,19 @@ func (h *Handler) startGatewayLocked() (int, error) {
|
|||
}
|
||||
|
||||
gateway.cmd = cmd
|
||||
gateway.bootDefaultModel = defaultModelName
|
||||
setGatewayRuntimeStatusLocked(initialStatus)
|
||||
pid := cmd.Process.Pid
|
||||
log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)
|
||||
|
||||
// Broadcast starting event
|
||||
gateway.events.Broadcast(GatewayEvent{Status: "starting", PID: pid})
|
||||
// Broadcast the launch state immediately so clients can reflect it without polling.
|
||||
gateway.events.Broadcast(GatewayEvent{
|
||||
Status: initialStatus,
|
||||
PID: pid,
|
||||
BootDefaultModel: defaultModelName,
|
||||
ConfigDefaultModel: defaultModelName,
|
||||
RestartRequired: false,
|
||||
})
|
||||
|
||||
// Capture stdout/stderr in background
|
||||
go scanPipe(stdoutPipe, gateway.logs)
|
||||
|
|
@ -190,13 +318,23 @@ func (h *Handler) startGatewayLocked() (int, error) {
|
|||
}
|
||||
|
||||
gateway.mu.Lock()
|
||||
shouldBroadcastStopped := false
|
||||
if gateway.cmd == cmd {
|
||||
gateway.cmd = nil
|
||||
gateway.bootDefaultModel = ""
|
||||
if gateway.runtimeStatus != "restarting" {
|
||||
setGatewayRuntimeStatusLocked("stopped")
|
||||
shouldBroadcastStopped = true
|
||||
}
|
||||
}
|
||||
gateway.mu.Unlock()
|
||||
|
||||
// Broadcast stopped event
|
||||
gateway.events.Broadcast(GatewayEvent{Status: "stopped"})
|
||||
if shouldBroadcastStopped {
|
||||
gateway.events.Broadcast(GatewayEvent{
|
||||
Status: "stopped",
|
||||
RestartRequired: false,
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
// Start a goroutine to probe health and broadcast "running" once ready
|
||||
|
|
@ -219,12 +357,22 @@ func (h *Handler) startGatewayLocked() (int, error) {
|
|||
healthPort = 18790
|
||||
}
|
||||
healthURL := fmt.Sprintf("http://%s/health", net.JoinHostPort(healthHost, strconv.Itoa(healthPort)))
|
||||
client := http.Client{Timeout: 1 * time.Second}
|
||||
resp, err := client.Get(healthURL)
|
||||
resp, err := gatewayHealthGet(healthURL, 1*time.Second)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
gateway.events.Broadcast(GatewayEvent{Status: "running", PID: pid})
|
||||
gateway.mu.Lock()
|
||||
if gateway.cmd == cmd {
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
}
|
||||
gateway.mu.Unlock()
|
||||
gateway.events.Broadcast(GatewayEvent{
|
||||
Status: "running",
|
||||
PID: pid,
|
||||
BootDefaultModel: defaultModelName,
|
||||
ConfigDefaultModel: defaultModelName,
|
||||
RestartRequired: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -253,6 +401,7 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
if gateway.cmd != nil && gateway.cmd.Process != nil {
|
||||
gateway.cmd = nil
|
||||
setGatewayRuntimeStatusLocked("stopped")
|
||||
}
|
||||
|
||||
ready, reason, err := h.gatewayStartReady()
|
||||
|
|
@ -274,7 +423,7 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
pid, err := h.startGatewayLocked()
|
||||
pid, err := h.startGatewayLocked("starting")
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -330,30 +479,72 @@ func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) {
|
|||
//
|
||||
// POST /api/gateway/restart
|
||||
func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
|
||||
gateway.mu.Lock()
|
||||
ready, reason, err := h.gatewayStartReady()
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf("Failed to validate gateway start conditions: %v", err),
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
if !ready {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "precondition_failed",
|
||||
"message": reason,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Stop existing process if running
|
||||
if gateway.cmd != nil && gateway.cmd.Process != nil {
|
||||
if isCmdProcessAliveLocked(gateway.cmd) {
|
||||
// Process is alive, send SIGTERM
|
||||
if runtime.GOOS == "windows" {
|
||||
gateway.cmd.Process.Kill()
|
||||
gateway.mu.Lock()
|
||||
previousCmd := gateway.cmd
|
||||
setGatewayRuntimeStatusLocked("restarting")
|
||||
gateway.events.Broadcast(GatewayEvent{
|
||||
Status: "restarting",
|
||||
RestartRequired: false,
|
||||
})
|
||||
gateway.mu.Unlock()
|
||||
|
||||
if err = stopGatewayProcessForRestart(previousCmd); err != nil {
|
||||
gateway.mu.Lock()
|
||||
if gateway.cmd == previousCmd {
|
||||
if isCmdProcessAliveLocked(previousCmd) {
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
} else {
|
||||
gateway.cmd.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
|
||||
// Wait briefly for it to exit
|
||||
gateway.mu.Unlock()
|
||||
time.Sleep(2 * time.Second)
|
||||
gateway.mu.Lock()
|
||||
}
|
||||
gateway.cmd = nil
|
||||
gateway.bootDefaultModel = ""
|
||||
setGatewayRuntimeStatusLocked("error")
|
||||
}
|
||||
}
|
||||
gateway.mu.Unlock()
|
||||
http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
gateway.mu.Lock()
|
||||
if gateway.cmd == previousCmd {
|
||||
gateway.cmd = nil
|
||||
gateway.bootDefaultModel = ""
|
||||
}
|
||||
pid, err := h.startGatewayLocked("restarting")
|
||||
if err != nil {
|
||||
gateway.cmd = nil
|
||||
gateway.bootDefaultModel = ""
|
||||
setGatewayRuntimeStatusLocked("error")
|
||||
}
|
||||
gateway.mu.Unlock()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Start fresh via the existing handler
|
||||
h.handleGatewayStart(w, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"pid": pid,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGatewayClearLogs clears the in-memory gateway log buffer.
|
||||
|
|
@ -370,28 +561,48 @@ func (h *Handler) handleGatewayClearLogs(w http.ResponseWriter, r *http.Request)
|
|||
})
|
||||
}
|
||||
|
||||
// handleGatewayStatus returns the gateway run status, health info, and logs.
|
||||
// handleGatewayStatus returns the gateway run status and health info.
|
||||
//
|
||||
// GET /api/gateway/status
|
||||
func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
|
||||
data := h.gatewayStatusData()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func (h *Handler) gatewayStatusData() map[string]any {
|
||||
data := map[string]any{}
|
||||
cfg, cfgErr := config.LoadConfig(h.configPath)
|
||||
configDefaultModel := ""
|
||||
if cfgErr == nil && cfg != nil {
|
||||
configDefaultModel = strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
|
||||
if configDefaultModel != "" {
|
||||
data["config_default_model"] = configDefaultModel
|
||||
}
|
||||
}
|
||||
|
||||
// Check process state
|
||||
gateway.mu.Lock()
|
||||
processAlive := isGatewayProcessAliveLocked()
|
||||
bootDefaultModel := ""
|
||||
if processAlive {
|
||||
data["pid"] = gateway.cmd.Process.Pid
|
||||
if gateway.bootDefaultModel != "" {
|
||||
data["boot_default_model"] = gateway.bootDefaultModel
|
||||
bootDefaultModel = gateway.bootDefaultModel
|
||||
}
|
||||
}
|
||||
gateway.mu.Unlock()
|
||||
|
||||
if !processAlive {
|
||||
data["gateway_status"] = "stopped"
|
||||
gateway.mu.Lock()
|
||||
data["gateway_status"] = currentGatewayStatusLocked(false)
|
||||
gateway.mu.Unlock()
|
||||
} else {
|
||||
// Process is alive — probe its health endpoint
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
host := "127.0.0.1"
|
||||
port := 18790
|
||||
if err == nil && cfg != nil {
|
||||
if cfgErr == nil && cfg != nil {
|
||||
host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
|
||||
if cfg.Gateway.Port != 0 {
|
||||
port = cfg.Gateway.Port
|
||||
|
|
@ -399,21 +610,31 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
client := http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
resp, err := gatewayHealthGet(url, 2*time.Second)
|
||||
|
||||
if err != nil {
|
||||
data["gateway_status"] = "starting"
|
||||
gateway.mu.Lock()
|
||||
data["gateway_status"] = currentGatewayStatusLocked(true)
|
||||
gateway.mu.Unlock()
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
gateway.mu.Lock()
|
||||
setGatewayRuntimeStatusLocked("error")
|
||||
gateway.mu.Unlock()
|
||||
data["gateway_status"] = "error"
|
||||
data["status_code"] = resp.StatusCode
|
||||
} else {
|
||||
var healthData map[string]any
|
||||
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
|
||||
gateway.mu.Lock()
|
||||
setGatewayRuntimeStatusLocked("error")
|
||||
gateway.mu.Unlock()
|
||||
data["gateway_status"] = "error"
|
||||
} else {
|
||||
gateway.mu.Lock()
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
gateway.mu.Unlock()
|
||||
for k, v := range healthData {
|
||||
data[k] = v
|
||||
}
|
||||
|
|
@ -423,6 +644,13 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
status, _ := data["gateway_status"].(string)
|
||||
data["gateway_restart_required"] = gatewayRestartRequired(
|
||||
status,
|
||||
bootDefaultModel,
|
||||
configDefaultModel,
|
||||
)
|
||||
|
||||
ready, reason, readyErr := h.gatewayStartReady()
|
||||
if readyErr != nil {
|
||||
data["gateway_start_allowed"] = false
|
||||
|
|
@ -434,16 +662,22 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// Append incremental log data
|
||||
appendGatewayLogs(r, data)
|
||||
return data
|
||||
}
|
||||
|
||||
// handleGatewayLogs returns buffered gateway logs, optionally incrementally.
|
||||
//
|
||||
// GET /api/gateway/logs
|
||||
func (h *Handler) handleGatewayLogs(w http.ResponseWriter, r *http.Request) {
|
||||
data := gatewayLogsData(r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// appendGatewayLogs reads log_offset and log_run_id query params from the request
|
||||
// and populates the response data map with incremental log lines.
|
||||
func appendGatewayLogs(r *http.Request, data map[string]any) {
|
||||
// gatewayLogsData reads log_offset and log_run_id query params from the request
|
||||
// and returns incremental log lines.
|
||||
func gatewayLogsData(r *http.Request) map[string]any {
|
||||
data := map[string]any{}
|
||||
clientOffset := 0
|
||||
clientRunID := -1
|
||||
|
||||
|
|
@ -465,7 +699,7 @@ func appendGatewayLogs(r *http.Request, data map[string]any) {
|
|||
data["logs"] = []string{}
|
||||
data["log_total"] = 0
|
||||
data["log_run_id"] = 0
|
||||
return
|
||||
return data
|
||||
}
|
||||
|
||||
// If runID changed, reset offset to get all logs from new run
|
||||
|
|
@ -482,6 +716,7 @@ func appendGatewayLogs(r *http.Request, data map[string]any) {
|
|||
data["logs"] = lines
|
||||
data["log_total"] = total
|
||||
data["log_run_id"] = runID
|
||||
return data
|
||||
}
|
||||
|
||||
// handleGatewayEvents serves an SSE stream of gateway state change events.
|
||||
|
|
@ -524,28 +759,7 @@ func (h *Handler) handleGatewayEvents(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// currentGatewayStatus returns the current gateway status as a JSON string.
|
||||
func (h *Handler) currentGatewayStatus() string {
|
||||
gateway.mu.Lock()
|
||||
defer gateway.mu.Unlock()
|
||||
|
||||
data := map[string]any{
|
||||
"gateway_status": "stopped",
|
||||
}
|
||||
if isGatewayProcessAliveLocked() {
|
||||
data["gateway_status"] = "running"
|
||||
data["pid"] = gateway.cmd.Process.Pid
|
||||
}
|
||||
|
||||
ready, reason, readyErr := h.gatewayStartReady()
|
||||
if readyErr != nil {
|
||||
data["gateway_start_allowed"] = false
|
||||
data["gateway_start_reason"] = readyErr.Error()
|
||||
} else {
|
||||
data["gateway_start_allowed"] = ready
|
||||
if !ready {
|
||||
data["gateway_start_reason"] = reason
|
||||
}
|
||||
}
|
||||
|
||||
data := h.gatewayStatusData()
|
||||
encoded, _ := json.Marshal(data)
|
||||
return string(encoded)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,19 +2,76 @@ package api
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||
)
|
||||
|
||||
func startLongRunningProcess(t *testing.T) *exec.Cmd {
|
||||
t.Helper()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("powershell", "-NoProfile", "-Command", "Start-Sleep -Seconds 30")
|
||||
} else {
|
||||
cmd = exec.Command("sleep", "30")
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func startIgnoringTermProcess(t *testing.T) *exec.Cmd {
|
||||
t.Helper()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("TERM handling differs on Windows")
|
||||
}
|
||||
|
||||
cmd := exec.Command("sh", "-c", "trap '' TERM; sleep 30")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func resetGatewayTestState(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
originalHealthGet := gatewayHealthGet
|
||||
originalRestartGracePeriod := gatewayRestartGracePeriod
|
||||
originalRestartForceKillWindow := gatewayRestartForceKillWindow
|
||||
originalRestartPollInterval := gatewayRestartPollInterval
|
||||
t.Cleanup(func() {
|
||||
gatewayHealthGet = originalHealthGet
|
||||
gatewayRestartGracePeriod = originalRestartGracePeriod
|
||||
gatewayRestartForceKillWindow = originalRestartForceKillWindow
|
||||
gatewayRestartPollInterval = originalRestartPollInterval
|
||||
|
||||
gateway.mu.Lock()
|
||||
gateway.cmd = nil
|
||||
gateway.bootDefaultModel = ""
|
||||
setGatewayRuntimeStatusLocked("stopped")
|
||||
gateway.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
|
@ -317,6 +374,412 @@ func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGatewayStatusKeepsRunningWhenHealthProbeFailsAfterRunning(t *testing.T) {
|
||||
resetGatewayTestState(t)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
cmd := startLongRunningProcess(t)
|
||||
t.Cleanup(func() {
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
})
|
||||
|
||||
gateway.mu.Lock()
|
||||
gateway.cmd = cmd
|
||||
gateway.bootDefaultModel = "existing-model"
|
||||
// Simulate a process that has already reached the running state.
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
gateway.mu.Unlock()
|
||||
|
||||
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
||||
return nil, errors.New("probe failed")
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if got := body["gateway_status"]; got != "running" {
|
||||
t.Fatalf("gateway_status = %#v, want %q", got, "running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStatusReturnsErrorAfterStartupWindowExpires(t *testing.T) {
|
||||
resetGatewayTestState(t)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
cmd := startLongRunningProcess(t)
|
||||
t.Cleanup(func() {
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
})
|
||||
|
||||
gateway.mu.Lock()
|
||||
gateway.cmd = cmd
|
||||
gateway.bootDefaultModel = "existing-model"
|
||||
setGatewayRuntimeStatusLocked("starting")
|
||||
gateway.startupDeadline = time.Now().Add(-time.Second)
|
||||
gateway.mu.Unlock()
|
||||
|
||||
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
||||
return nil, errors.New("probe failed")
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if got := body["gateway_status"]; got != "error" {
|
||||
t.Fatalf("gateway_status = %#v, want %q", got, "error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStatusReturnsRestartingDuringRestartGap(t *testing.T) {
|
||||
resetGatewayTestState(t)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
gateway.mu.Lock()
|
||||
setGatewayRuntimeStatusLocked("restarting")
|
||||
gateway.mu.Unlock()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if got := body["gateway_status"]; got != "restarting" {
|
||||
t.Fatalf("gateway_status = %#v, want %q", got, "restarting")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStatusIncludesRestartRequiredWhenModelsDiffer(t *testing.T) {
|
||||
resetGatewayTestState(t)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||
cfg.ModelList[0].APIKey = "test-key"
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
cmd := startLongRunningProcess(t)
|
||||
t.Cleanup(func() {
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
})
|
||||
|
||||
gateway.mu.Lock()
|
||||
gateway.cmd = cmd
|
||||
gateway.bootDefaultModel = "previous-model"
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
gateway.mu.Unlock()
|
||||
|
||||
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
||||
rec := httptest.NewRecorder()
|
||||
rec.WriteHeader(http.StatusOK)
|
||||
_, _ = rec.WriteString(`{"ok":true}`)
|
||||
return rec.Result(), nil
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if got := body["gateway_restart_required"]; got != true {
|
||||
t.Fatalf("gateway_restart_required = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayRestartKeepsRunningProcessWhenPreconditionsFail(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||
cfg.ModelList[0].APIKey = ""
|
||||
cfg.ModelList[0].AuthMethod = ""
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
cmd := startLongRunningProcess(t)
|
||||
t.Cleanup(func() {
|
||||
gateway.mu.Lock()
|
||||
if gateway.cmd == cmd {
|
||||
gateway.cmd = nil
|
||||
gateway.bootDefaultModel = ""
|
||||
}
|
||||
gateway.mu.Unlock()
|
||||
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
})
|
||||
|
||||
gateway.mu.Lock()
|
||||
gateway.cmd = cmd
|
||||
gateway.bootDefaultModel = "existing-model"
|
||||
gateway.mu.Unlock()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
gateway.mu.Lock()
|
||||
stillRunning := gateway.cmd == cmd && isCmdProcessAliveLocked(cmd)
|
||||
gateway.mu.Unlock()
|
||||
|
||||
if !stillRunning {
|
||||
t.Fatalf("gateway process was stopped when restart preconditions failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayRestartKeepsOldProcessWhenItDoesNotExitInTime(t *testing.T) {
|
||||
resetGatewayTestState(t)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||
cfg.ModelList[0].APIKey = "test-key"
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
cmd := startIgnoringTermProcess(t)
|
||||
t.Cleanup(func() {
|
||||
gateway.mu.Lock()
|
||||
if gateway.cmd == cmd {
|
||||
gateway.cmd = nil
|
||||
gateway.bootDefaultModel = ""
|
||||
}
|
||||
gateway.mu.Unlock()
|
||||
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
})
|
||||
|
||||
gatewayRestartGracePeriod = 150 * time.Millisecond
|
||||
gatewayRestartForceKillWindow = 150 * time.Millisecond
|
||||
gatewayRestartPollInterval = 10 * time.Millisecond
|
||||
|
||||
gateway.mu.Lock()
|
||||
gateway.cmd = cmd
|
||||
gateway.bootDefaultModel = "existing-model"
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
gateway.mu.Unlock()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
gateway.mu.Lock()
|
||||
stillRunning := gateway.cmd == cmd && isCmdProcessAliveLocked(cmd)
|
||||
status := gateway.runtimeStatus
|
||||
gateway.mu.Unlock()
|
||||
|
||||
if !stillRunning {
|
||||
t.Fatalf("gateway process was replaced before the old process exited")
|
||||
}
|
||||
if status != "running" {
|
||||
t.Fatalf("runtimeStatus = %q, want %q", status, "running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayRestartReturnsErrorStatusWhenReplacementFailsToStart(t *testing.T) {
|
||||
resetGatewayTestState(t)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||
cfg.ModelList[0].APIKey = "test-key"
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
invalidBinaryPath := filepath.Join(t.TempDir(), "fake-picoclaw")
|
||||
if err := os.WriteFile(invalidBinaryPath, []byte("#!/bin/sh\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
t.Setenv("PICOCLAW_BINARY", invalidBinaryPath)
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("restart status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
statusRec := httptest.NewRecorder()
|
||||
statusReq := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
||||
mux.ServeHTTP(statusRec, statusReq)
|
||||
|
||||
if statusRec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", statusRec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(statusRec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if got := body["gateway_status"]; got != "error" {
|
||||
t.Fatalf("gateway_status = %#v, want %q", got, "error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStatusExcludesLogsFields(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := body["logs"]; ok {
|
||||
t.Fatalf("logs unexpectedly present in status response: %#v", body["logs"])
|
||||
}
|
||||
if _, ok := body["log_total"]; ok {
|
||||
t.Fatalf("log_total unexpectedly present in status response: %#v", body["log_total"])
|
||||
}
|
||||
if _, ok := body["log_run_id"]; ok {
|
||||
t.Fatalf("log_run_id unexpectedly present in status response: %#v", body["log_run_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayLogsReturnsIncrementalHistory(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
gateway.logs.Clear()
|
||||
gateway.logs.Append("first line")
|
||||
gateway.logs.Append("second line")
|
||||
runID := gateway.logs.RunID()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/gateway/logs?log_offset=1&log_run_id="+strconv.Itoa(runID),
|
||||
nil,
|
||||
)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("logs status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal logs response: %v", err)
|
||||
}
|
||||
|
||||
logs, ok := body["logs"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("logs missing or not array: %#v", body["logs"])
|
||||
}
|
||||
if len(logs) != 1 || logs[0] != "second line" {
|
||||
t.Fatalf("logs = %#v, want [\"second line\"]", logs)
|
||||
}
|
||||
if got := body["log_total"]; got != float64(2) {
|
||||
t.Fatalf("log_total = %#v, want 2", got)
|
||||
}
|
||||
if got := body["log_run_id"]; got != float64(runID) {
|
||||
t.Fatalf("log_run_id = %#v, want %d", got, runID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
|
@ -353,33 +816,36 @@ func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {
|
|||
t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID)
|
||||
}
|
||||
|
||||
statusRec := httptest.NewRecorder()
|
||||
statusReq := httptest.NewRequest(
|
||||
logsRec := httptest.NewRecorder()
|
||||
logsReq := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/gateway/status?log_offset=0&log_run_id="+strconv.Itoa(previousRunID),
|
||||
"/api/gateway/logs?log_offset=0&log_run_id="+strconv.Itoa(previousRunID),
|
||||
nil,
|
||||
)
|
||||
mux.ServeHTTP(statusRec, statusReq)
|
||||
mux.ServeHTTP(logsRec, logsReq)
|
||||
|
||||
if statusRec.Code != http.StatusOK {
|
||||
t.Fatalf("status code = %d, want %d", statusRec.Code, http.StatusOK)
|
||||
if logsRec.Code != http.StatusOK {
|
||||
t.Fatalf("logs code = %d, want %d", logsRec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var statusBody map[string]any
|
||||
if err := json.Unmarshal(statusRec.Body.Bytes(), &statusBody); err != nil {
|
||||
t.Fatalf("unmarshal status response: %v", err)
|
||||
var logsBody map[string]any
|
||||
if err := json.Unmarshal(logsRec.Body.Bytes(), &logsBody); err != nil {
|
||||
t.Fatalf("unmarshal logs response: %v", err)
|
||||
}
|
||||
|
||||
logs, ok := statusBody["logs"].([]any)
|
||||
logs, ok := logsBody["logs"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("logs missing or not array: %#v", statusBody["logs"])
|
||||
t.Fatalf("logs missing or not array: %#v", logsBody["logs"])
|
||||
}
|
||||
if len(logs) != 0 {
|
||||
t.Fatalf("logs len = %d, want 0", len(logs))
|
||||
}
|
||||
if got := statusBody["log_total"]; got != float64(0) {
|
||||
if got := logsBody["log_total"]; got != float64(0) {
|
||||
t.Fatalf("log_total = %#v, want 0", got)
|
||||
}
|
||||
if got := logsBody["log_run_id"]; got != clearBody["log_run_id"] {
|
||||
t.Fatalf("log_run_id = %#v, want %#v", got, clearBody["log_run_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPicoclawBinary_EnvOverride(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -65,9 +65,14 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
}
|
||||
|
||||
// ensurePicoChannel checks if the Pico Channel is properly configured and
|
||||
// enables it with sensible defaults if not. Returns true if config was changed.
|
||||
func (h *Handler) ensurePicoChannel() (bool, error) {
|
||||
// ensurePicoChannel enables the Pico channel with sane defaults if it isn't
|
||||
// already configured. Returns true when the config was modified.
|
||||
//
|
||||
// callerOrigin is the Origin header from the setup request. If non-empty and
|
||||
// no origins are configured yet, it's written as the allowed origin so the
|
||||
// WebSocket handshake works for whatever host the caller is on (LAN, custom
|
||||
// port, etc.). Pass "" when there's no request context.
|
||||
func (h *Handler) ensurePicoChannel(callerOrigin string) (bool, error) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to load config: %w", err)
|
||||
|
|
@ -85,14 +90,9 @@ func (h *Handler) ensurePicoChannel() (bool, error) {
|
|||
changed = true
|
||||
}
|
||||
|
||||
if !cfg.Channels.Pico.AllowTokenQuery {
|
||||
cfg.Channels.Pico.AllowTokenQuery = true
|
||||
changed = true
|
||||
}
|
||||
|
||||
// Make sure origins are allowed (frontend might be running on a different port like 5173 during dev)
|
||||
if len(cfg.Channels.Pico.AllowOrigins) == 0 {
|
||||
cfg.Channels.Pico.AllowOrigins = []string{"*"}
|
||||
// Seed origins from the request instead of hardcoding ports.
|
||||
if len(cfg.Channels.Pico.AllowOrigins) == 0 && callerOrigin != "" {
|
||||
cfg.Channels.Pico.AllowOrigins = []string{callerOrigin}
|
||||
changed = true
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ func (h *Handler) ensurePicoChannel() (bool, error) {
|
|||
//
|
||||
// POST /api/pico/setup
|
||||
func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
|
||||
changed, err := h.ensurePicoChannel()
|
||||
changed, err := h.ensurePicoChannel(r.Header.Get("Origin"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
|
|||
237
web/backend/api/pico_test.go
Normal file
237
web/backend/api/pico_test.go
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestEnsurePicoChannel_FreshConfig(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
changed, err := h.ensurePicoChannel("")
|
||||
if err != nil {
|
||||
t.Fatalf("ensurePicoChannel() error = %v", err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("ensurePicoChannel() should report changed on a fresh config")
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if !cfg.Channels.Pico.Enabled {
|
||||
t.Error("expected Pico to be enabled after setup")
|
||||
}
|
||||
if cfg.Channels.Pico.Token == "" {
|
||||
t.Error("expected a non-empty token after setup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
if _, err := h.ensurePicoChannel(""); err != nil {
|
||||
t.Fatalf("ensurePicoChannel() error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Channels.Pico.AllowTokenQuery {
|
||||
t.Error("setup must not enable allow_token_query by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
if _, err := h.ensurePicoChannel("http://localhost:18800"); err != nil {
|
||||
t.Fatalf("ensurePicoChannel() error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
for _, origin := range cfg.Channels.Pico.AllowOrigins {
|
||||
if origin == "*" {
|
||||
t.Error("setup must not set wildcard origin '*'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePicoChannel_NoOriginWithoutCaller(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
if _, err := h.ensurePicoChannel(""); err != nil {
|
||||
t.Fatalf("ensurePicoChannel() error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
// Without a caller origin, allow_origins stays empty (CheckOrigin
|
||||
// allows all when the list is empty, so the channel still works).
|
||||
if len(cfg.Channels.Pico.AllowOrigins) != 0 {
|
||||
t.Errorf("allow_origins = %v, want empty when no caller origin", cfg.Channels.Pico.AllowOrigins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePicoChannel_SetsCallerOrigin(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
lanOrigin := "http://192.168.1.9:18800"
|
||||
if _, err := h.ensurePicoChannel(lanOrigin); err != nil {
|
||||
t.Fatalf("ensurePicoChannel() error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != lanOrigin {
|
||||
t.Errorf("allow_origins = %v, want [%s]", cfg.Channels.Pico.AllowOrigins, lanOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
// Pre-configure with custom user settings
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Channels.Pico.Enabled = true
|
||||
cfg.Channels.Pico.Token = "user-custom-token"
|
||||
cfg.Channels.Pico.AllowTokenQuery = true
|
||||
cfg.Channels.Pico.AllowOrigins = []string{"https://myapp.example.com"}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
|
||||
changed, err := h.ensurePicoChannel("")
|
||||
if err != nil {
|
||||
t.Fatalf("ensurePicoChannel() error = %v", err)
|
||||
}
|
||||
if changed {
|
||||
t.Error("ensurePicoChannel() should not change a fully configured config")
|
||||
}
|
||||
|
||||
cfg, err = config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Channels.Pico.Token != "user-custom-token" {
|
||||
t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token, "user-custom-token")
|
||||
}
|
||||
if !cfg.Channels.Pico.AllowTokenQuery {
|
||||
t.Error("user's allow_token_query=true must be preserved")
|
||||
}
|
||||
if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != "https://myapp.example.com" {
|
||||
t.Errorf("allow_origins = %v, want [https://myapp.example.com]", cfg.Channels.Pico.AllowOrigins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePicoChannel_Idempotent(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
origin := "http://localhost:18800"
|
||||
|
||||
// First call sets things up
|
||||
if _, err := h.ensurePicoChannel(origin); err != nil {
|
||||
t.Fatalf("first ensurePicoChannel() error = %v", err)
|
||||
}
|
||||
|
||||
cfg1, _ := config.LoadConfig(configPath)
|
||||
token1 := cfg1.Channels.Pico.Token
|
||||
|
||||
// Second call should be a no-op
|
||||
changed, err := h.ensurePicoChannel(origin)
|
||||
if err != nil {
|
||||
t.Fatalf("second ensurePicoChannel() error = %v", err)
|
||||
}
|
||||
if changed {
|
||||
t.Error("second ensurePicoChannel() should not report changed")
|
||||
}
|
||||
|
||||
cfg2, _ := config.LoadConfig(configPath)
|
||||
if cfg2.Channels.Pico.Token != token1 {
|
||||
t.Error("token should not change on subsequent calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePicoSetup_IncludesRequestOrigin(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/pico/setup", nil)
|
||||
req.Header.Set("Origin", "http://10.0.0.5:3000")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.handlePicoSetup(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != "http://10.0.0.5:3000" {
|
||||
t.Errorf("allow_origins = %v, want [http://10.0.0.5:3000]", cfg.Channels.Pico.AllowOrigins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePicoSetup_Response(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/pico/setup", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.handlePicoSetup(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if resp["token"] == nil || resp["token"] == "" {
|
||||
t.Error("response should contain a non-empty token")
|
||||
}
|
||||
if resp["ws_url"] == nil || resp["ws_url"] == "" {
|
||||
t.Error("response should contain ws_url")
|
||||
}
|
||||
if resp["enabled"] != true {
|
||||
t.Error("response should have enabled=true")
|
||||
}
|
||||
if resp["changed"] != true {
|
||||
t.Error("response should have changed=true on first setup")
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
|
@ -14,6 +15,13 @@ var frontendFS embed.FS
|
|||
|
||||
// registerEmbedRoutes sets up the HTTP handler to serve the embedded frontend files
|
||||
func registerEmbedRoutes(mux *http.ServeMux) {
|
||||
// Register correct MIME type for SVG files
|
||||
// Go's built-in mime.TypeByExtension returns "image/svg" which is incorrect
|
||||
// The correct MIME type per RFC 6838 is "image/svg+xml"
|
||||
if err := mime.AddExtensionType(".svg", "image/svg+xml"); err != nil {
|
||||
log.Printf("Warning: failed to register SVG MIME type: %v", err)
|
||||
}
|
||||
|
||||
// Attempt to get the subdirectory 'dist' where Vite usually builds
|
||||
subFS, err := fs.Sub(frontendFS, "dist")
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -17,18 +17,18 @@
|
|||
"@tabler/icons-react": "^3.38.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.163.3",
|
||||
"@tanstack/react-router": "^1.167.0",
|
||||
"@tanstack/react-router-devtools": "^1.163.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.20",
|
||||
"i18next": "^25.8.14",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jotai": "^2.18.0",
|
||||
"jotai": "^2.18.1",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-i18next": "^16.5.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-gfm": "^4.0.1",
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
|
|
|
|||
127
web/frontend/pnpm-lock.yaml
generated
127
web/frontend/pnpm-lock.yaml
generated
|
|
@ -21,11 +21,11 @@ importers:
|
|||
specifier: ^5.90.21
|
||||
version: 5.90.21(react@19.2.4)
|
||||
'@tanstack/react-router':
|
||||
specifier: ^1.163.3
|
||||
version: 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
specifier: ^1.167.0
|
||||
version: 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tanstack/react-router-devtools':
|
||||
specifier: ^1.163.3
|
||||
version: 1.163.3(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.3)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
version: 1.163.3(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.0)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
|
|
@ -33,8 +33,8 @@ importers:
|
|||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
dayjs:
|
||||
specifier: ^1.11.19
|
||||
version: 1.11.19
|
||||
specifier: ^1.11.20
|
||||
version: 1.11.20
|
||||
i18next:
|
||||
specifier: ^25.8.14
|
||||
version: 25.8.14(typescript@5.9.3)
|
||||
|
|
@ -42,8 +42,8 @@ importers:
|
|||
specifier: ^8.2.1
|
||||
version: 8.2.1
|
||||
jotai:
|
||||
specifier: ^2.18.0
|
||||
version: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4)
|
||||
specifier: ^2.18.1
|
||||
version: 2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4)
|
||||
radix-ui:
|
||||
specifier: ^1.4.3
|
||||
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -54,8 +54,8 @@ importers:
|
|||
specifier: ^19.2.0
|
||||
version: 19.2.4(react@19.2.4)
|
||||
react-i18next:
|
||||
specifier: ^16.5.4
|
||||
version: 16.5.4(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
||||
specifier: ^16.5.8
|
||||
version: 16.5.8(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
|
||||
|
|
@ -92,7 +92,7 @@ importers:
|
|||
version: 0.5.19(tailwindcss@4.2.1)
|
||||
'@tanstack/router-plugin':
|
||||
specifier: ^1.164.0
|
||||
version: 1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
|
||||
version: 1.164.0(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
|
||||
'@trivago/prettier-plugin-sort-imports':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(prettier@3.8.1)
|
||||
|
|
@ -109,8 +109,8 @@ importers:
|
|||
specifier: ^8.56.1
|
||||
version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.1.1
|
||||
version: 5.1.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
|
||||
eslint:
|
||||
specifier: ^9.39.1
|
||||
version: 9.39.3(jiti@2.6.1)
|
||||
|
|
@ -1587,15 +1587,15 @@ packages:
|
|||
'@tanstack/router-core':
|
||||
optional: true
|
||||
|
||||
'@tanstack/react-router@1.163.3':
|
||||
resolution: {integrity: sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q==}
|
||||
'@tanstack/react-router@1.167.0':
|
||||
resolution: {integrity: sha512-U7CamtXjuC8ixg1c32Rj/4A2OFBnjtMLdbgbyOGHrFHE7ULWS/yhnZLVXff0QSyn6qF92Oecek9mDMHCaTnB2Q==}
|
||||
engines: {node: '>=20.19'}
|
||||
peerDependencies:
|
||||
react: '>=18.0.0 || >=19.0.0'
|
||||
react-dom: '>=18.0.0 || >=19.0.0'
|
||||
|
||||
'@tanstack/react-store@0.9.1':
|
||||
resolution: {integrity: sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==}
|
||||
'@tanstack/react-store@0.9.2':
|
||||
resolution: {integrity: sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
|
@ -1604,6 +1604,10 @@ packages:
|
|||
resolution: {integrity: sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
||||
'@tanstack/router-core@1.167.0':
|
||||
resolution: {integrity: sha512-pnaaUP+vMQEyL2XjZGe2PXmtzulxvXfGyvEMUs+AEBaNEk77xWA88bl3ujiBRbUxzpK0rxfJf+eSKPdZmBMFdQ==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
||||
'@tanstack/router-devtools-core@1.163.3':
|
||||
resolution: {integrity: sha512-FPi64IP0PT1IkoeyGmsD6JoOVOYAb85VCH0mUbSdD90yV0+1UB6oT+D7K27GXkp7SXMJN3mBEjU5rKnNnmSCIw==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
|
@ -1646,6 +1650,9 @@ packages:
|
|||
'@tanstack/store@0.9.1':
|
||||
resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==}
|
||||
|
||||
'@tanstack/store@0.9.2':
|
||||
resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==}
|
||||
|
||||
'@tanstack/virtual-file-routes@1.161.4':
|
||||
resolution: {integrity: sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
|
@ -1790,11 +1797,11 @@ packages:
|
|||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
|
||||
'@vitejs/plugin-react@5.1.4':
|
||||
resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==}
|
||||
'@vitejs/plugin-react@5.2.0':
|
||||
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
|
|
@ -2060,8 +2067,8 @@ packages:
|
|||
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
dayjs@1.11.19:
|
||||
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
|
||||
dayjs@1.11.20:
|
||||
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
|
|
@ -2648,8 +2655,8 @@ packages:
|
|||
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
isbot@5.1.35:
|
||||
resolution: {integrity: sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==}
|
||||
isbot@5.1.36:
|
||||
resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
isexe@2.0.0:
|
||||
|
|
@ -2669,8 +2676,8 @@ packages:
|
|||
jose@6.1.3:
|
||||
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||
|
||||
jotai@2.18.0:
|
||||
resolution: {integrity: sha512-XI38kGWAvtxAZ+cwHcTgJsd+kJOJGf3OfL4XYaXWZMZ7IIY8e53abpIHvtVn1eAgJ5dlgwlGFnP4psrZ/vZbtA==}
|
||||
jotai@2.18.1:
|
||||
resolution: {integrity: sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': '>=7.0.0'
|
||||
|
|
@ -3323,8 +3330,8 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^19.2.4
|
||||
|
||||
react-i18next@16.5.4:
|
||||
resolution: {integrity: sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==}
|
||||
react-i18next@16.5.8:
|
||||
resolution: {integrity: sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==}
|
||||
peerDependencies:
|
||||
i18next: '>= 25.6.2'
|
||||
react: '>= 16.8.0'
|
||||
|
|
@ -3476,10 +3483,20 @@ packages:
|
|||
peerDependencies:
|
||||
seroval: ^1.0
|
||||
|
||||
seroval-plugins@1.5.1:
|
||||
resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
seroval: ^1.0
|
||||
|
||||
seroval@1.5.0:
|
||||
resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
seroval@1.5.1:
|
||||
resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
serve-static@2.2.1:
|
||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
|
@ -5365,31 +5382,31 @@ snapshots:
|
|||
'@tanstack/query-core': 5.90.20
|
||||
react: 19.2.4
|
||||
|
||||
'@tanstack/react-router-devtools@1.163.3(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.3)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
'@tanstack/react-router-devtools@1.163.3(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.0)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tanstack/router-devtools-core': 1.163.3(@tanstack/router-core@1.163.3)(csstype@3.2.3)
|
||||
'@tanstack/react-router': 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tanstack/router-devtools-core': 1.163.3(@tanstack/router-core@1.167.0)(csstype@3.2.3)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@tanstack/router-core': 1.163.3
|
||||
'@tanstack/router-core': 1.167.0
|
||||
transitivePeerDependencies:
|
||||
- csstype
|
||||
|
||||
'@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
'@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/history': 1.161.4
|
||||
'@tanstack/react-store': 0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tanstack/router-core': 1.163.3
|
||||
isbot: 5.1.35
|
||||
'@tanstack/react-store': 0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tanstack/router-core': 1.167.0
|
||||
isbot: 5.1.36
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
tiny-invariant: 1.3.3
|
||||
tiny-warning: 1.0.3
|
||||
|
||||
'@tanstack/react-store@0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
'@tanstack/react-store@0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.9.1
|
||||
'@tanstack/store': 0.9.2
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
|
|
@ -5404,9 +5421,19 @@ snapshots:
|
|||
tiny-invariant: 1.3.3
|
||||
tiny-warning: 1.0.3
|
||||
|
||||
'@tanstack/router-devtools-core@1.163.3(@tanstack/router-core@1.163.3)(csstype@3.2.3)':
|
||||
'@tanstack/router-core@1.167.0':
|
||||
dependencies:
|
||||
'@tanstack/router-core': 1.163.3
|
||||
'@tanstack/history': 1.161.4
|
||||
'@tanstack/store': 0.9.2
|
||||
cookie-es: 2.0.0
|
||||
seroval: 1.5.1
|
||||
seroval-plugins: 1.5.1(seroval@1.5.1)
|
||||
tiny-invariant: 1.3.3
|
||||
tiny-warning: 1.0.3
|
||||
|
||||
'@tanstack/router-devtools-core@1.163.3(@tanstack/router-core@1.167.0)(csstype@3.2.3)':
|
||||
dependencies:
|
||||
'@tanstack/router-core': 1.167.0
|
||||
clsx: 2.1.1
|
||||
goober: 2.1.18(csstype@3.2.3)
|
||||
tiny-invariant: 1.3.3
|
||||
|
|
@ -5426,7 +5453,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))':
|
||||
'@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
|
||||
|
|
@ -5442,7 +5469,7 @@ snapshots:
|
|||
unplugin: 2.3.11
|
||||
zod: 3.25.76
|
||||
optionalDependencies:
|
||||
'@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tanstack/react-router': 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -5463,6 +5490,8 @@ snapshots:
|
|||
|
||||
'@tanstack/store@0.9.1': {}
|
||||
|
||||
'@tanstack/store@0.9.2': {}
|
||||
|
||||
'@tanstack/virtual-file-routes@1.161.4': {}
|
||||
|
||||
'@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)':
|
||||
|
|
@ -5641,7 +5670,7 @@ snapshots:
|
|||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))':
|
||||
'@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
|
||||
|
|
@ -5894,7 +5923,7 @@ snapshots:
|
|||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
|
||||
dayjs@1.11.19: {}
|
||||
dayjs@1.11.20: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
|
|
@ -6489,7 +6518,7 @@ snapshots:
|
|||
dependencies:
|
||||
is-inside-container: 1.0.0
|
||||
|
||||
isbot@5.1.35: {}
|
||||
isbot@5.1.36: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
|
|
@ -6501,7 +6530,7 @@ snapshots:
|
|||
|
||||
jose@6.1.3: {}
|
||||
|
||||
jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4):
|
||||
jotai@2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4):
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/template': 7.28.6
|
||||
|
|
@ -7310,7 +7339,7 @@ snapshots:
|
|||
react: 19.2.4
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-i18next@16.5.4(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
|
||||
react-i18next@16.5.8(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
html-parse-stringify: 3.0.1
|
||||
|
|
@ -7517,8 +7546,14 @@ snapshots:
|
|||
dependencies:
|
||||
seroval: 1.5.0
|
||||
|
||||
seroval-plugins@1.5.1(seroval@1.5.1):
|
||||
dependencies:
|
||||
seroval: 1.5.1
|
||||
|
||||
seroval@1.5.0: {}
|
||||
|
||||
seroval@1.5.1: {}
|
||||
|
||||
serve-static@2.2.1:
|
||||
dependencies:
|
||||
encodeurl: 2.0.0
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
// API client for gateway process management.
|
||||
|
||||
interface GatewayStatusResponse {
|
||||
gateway_status: "running" | "starting" | "stopped" | "error"
|
||||
gateway_status: "running" | "starting" | "restarting" | "stopped" | "error"
|
||||
gateway_start_allowed?: boolean
|
||||
gateway_start_reason?: string
|
||||
gateway_restart_required?: boolean
|
||||
pid?: number
|
||||
boot_default_model?: string
|
||||
config_default_model?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface GatewayLogsResponse {
|
||||
logs?: string[]
|
||||
log_total?: number
|
||||
log_run_id?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface GatewayActionResponse {
|
||||
|
|
@ -28,10 +34,14 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function getGatewayStatus(options?: {
|
||||
export async function getGatewayStatus(): Promise<GatewayStatusResponse> {
|
||||
return request<GatewayStatusResponse>("/api/gateway/status")
|
||||
}
|
||||
|
||||
export async function getGatewayLogs(options?: {
|
||||
log_offset?: number
|
||||
log_run_id?: number
|
||||
}): Promise<GatewayStatusResponse> {
|
||||
}): Promise<GatewayLogsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (options?.log_offset !== undefined) {
|
||||
params.set("log_offset", options.log_offset.toString())
|
||||
|
|
@ -40,7 +50,7 @@ export async function getGatewayStatus(options?: {
|
|||
params.set("log_run_id", options.log_run_id.toString())
|
||||
}
|
||||
const queryString = params.toString() ? `?${params.toString()}` : ""
|
||||
return request<GatewayStatusResponse>(`/api/gateway/status${queryString}`)
|
||||
return request<GatewayLogsResponse>(`/api/gateway/logs${queryString}`)
|
||||
}
|
||||
|
||||
export async function startGateway(): Promise<GatewayActionResponse> {
|
||||
|
|
@ -67,4 +77,8 @@ export async function clearGatewayLogs(): Promise<GatewayActionResponse> {
|
|||
})
|
||||
}
|
||||
|
||||
export type { GatewayStatusResponse, GatewayActionResponse }
|
||||
export type {
|
||||
GatewayStatusResponse,
|
||||
GatewayLogsResponse,
|
||||
GatewayActionResponse,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export async function setDefaultModel(
|
|||
body: JSON.stringify({ model_name: modelName }),
|
||||
})
|
||||
|
||||
void refreshGatewayState()
|
||||
await refreshGatewayState()
|
||||
return response
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
IconMoon,
|
||||
IconPlayerPlay,
|
||||
IconPower,
|
||||
IconRefresh,
|
||||
IconSun,
|
||||
} from "@tabler/icons-react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
|
|
@ -31,6 +32,11 @@ import {
|
|||
} from "@/components/ui/dropdown-menu.tsx"
|
||||
import { Separator } from "@/components/ui/separator.tsx"
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useGateway } from "@/hooks/use-gateway.ts"
|
||||
import { useTheme } from "@/hooks/use-theme.ts"
|
||||
|
||||
|
|
@ -41,27 +47,35 @@ export function AppHeader() {
|
|||
state: gwState,
|
||||
loading: gwLoading,
|
||||
canStart,
|
||||
restartRequired,
|
||||
start,
|
||||
restart,
|
||||
stop,
|
||||
} = useGateway()
|
||||
|
||||
const isRunning = gwState === "running"
|
||||
const isStarting = gwState === "starting"
|
||||
const isRestarting = gwState === "restarting"
|
||||
const isStopped = gwState === "stopped" || gwState === "unknown"
|
||||
const showNotConnectedHint =
|
||||
canStart && (gwState === "stopped" || gwState === "error")
|
||||
!isRestarting && canStart && (gwState === "stopped" || gwState === "error")
|
||||
|
||||
const [showStopDialog, setShowStopDialog] = React.useState(false)
|
||||
|
||||
const handleGatewayToggle = () => {
|
||||
if (gwLoading || (!isRunning && !canStart)) return
|
||||
if (gwLoading || isRestarting || (!isRunning && !canStart)) return
|
||||
if (isRunning) {
|
||||
setShowStopDialog(true)
|
||||
} else {
|
||||
start()
|
||||
void start()
|
||||
}
|
||||
}
|
||||
|
||||
const handleGatewayRestart = () => {
|
||||
if (gwLoading || isRestarting || !restartRequired || !canStart) return
|
||||
void restart()
|
||||
}
|
||||
|
||||
const confirmStop = () => {
|
||||
setShowStopDialog(false)
|
||||
stop()
|
||||
|
|
@ -115,35 +129,67 @@ export function AppHeader() {
|
|||
</AlertDialog>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-sm font-medium md:gap-2">
|
||||
{/* Gateway Start/Stop */}
|
||||
{restartRequired && (
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={isStarting ? "secondary" : "default"}
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
className="bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 hover:text-amber-800 dark:text-amber-300 dark:hover:bg-amber-500/25"
|
||||
onClick={handleGatewayRestart}
|
||||
disabled={gwLoading || isRestarting || !canStart}
|
||||
aria-label={t("header.gateway.action.restart")}
|
||||
>
|
||||
<IconRefresh className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("header.gateway.restartRequired")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Gateway Start/Stop */}
|
||||
{isRunning ? (
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
className="size-8"
|
||||
onClick={handleGatewayToggle}
|
||||
disabled={gwLoading}
|
||||
aria-label={t("header.gateway.action.stop")}
|
||||
>
|
||||
<IconPower className="h-4 w-4 opacity-80" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("header.gateway.action.stop")}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant={isStarting || isRestarting ? "secondary" : "default"}
|
||||
size="sm"
|
||||
className={`h-8 gap-2 px-3 ${
|
||||
isRunning
|
||||
? "bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||
: isStopped
|
||||
? "bg-green-500 text-white hover:bg-green-600"
|
||||
: ""
|
||||
isStopped ? "bg-green-500 text-white hover:bg-green-600" : ""
|
||||
}`}
|
||||
onClick={handleGatewayToggle}
|
||||
disabled={gwLoading || isStarting || (!isRunning && !canStart)}
|
||||
disabled={gwLoading || isStarting || isRestarting || !canStart}
|
||||
>
|
||||
{gwLoading || isStarting ? (
|
||||
{gwLoading || isStarting || isRestarting ? (
|
||||
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
|
||||
) : isRunning ? (
|
||||
<IconPower className="h-4 w-4 opacity-80" />
|
||||
) : (
|
||||
<IconPlayerPlay className="h-4 w-4 opacity-80" />
|
||||
)}
|
||||
<span className="text-xs font-semibold">
|
||||
{isRunning
|
||||
? t("header.gateway.action.stop")
|
||||
{isRestarting
|
||||
? t("header.gateway.status.restarting")
|
||||
: isStarting
|
||||
? t("header.gateway.status.starting")
|
||||
: t("header.gateway.action.start")}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Separator
|
||||
className="mx-4 my-2 hidden md:block"
|
||||
|
|
|
|||
|
|
@ -15,11 +15,13 @@ import { useChatModels } from "@/hooks/use-chat-models"
|
|||
import { useGateway } from "@/hooks/use-gateway"
|
||||
import { usePicoChat } from "@/hooks/use-pico-chat"
|
||||
import { useSessionHistory } from "@/hooks/use-session-history"
|
||||
import { hydrateActiveSession } from "@/lib/pico-chat-controller"
|
||||
|
||||
export function ChatPage() {
|
||||
const { t } = useTranslation()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [isAtBottom, setIsAtBottom] = useState(true)
|
||||
const [hasScrolled, setHasScrolled] = useState(false)
|
||||
const [input, setInput] = useState("")
|
||||
|
||||
const {
|
||||
|
|
@ -56,15 +58,27 @@ export function ChatPage() {
|
|||
onDeletedActiveSession: newChat,
|
||||
})
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
|
||||
const syncScrollState = (element: HTMLDivElement) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = element
|
||||
setHasScrolled(scrollTop > 0)
|
||||
setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)
|
||||
}
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
syncScrollState(e.currentTarget)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isAtBottom && scrollRef.current) {
|
||||
void hydrateActiveSession()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
if (isAtBottom) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
syncScrollState(scrollRef.current)
|
||||
}
|
||||
}, [messages, isTyping, isAtBottom])
|
||||
|
||||
const handleSend = () => {
|
||||
|
|
@ -77,6 +91,9 @@ export function ChatPage() {
|
|||
<div className="bg-background/95 flex h-full flex-col">
|
||||
<PageHeader
|
||||
title={t("navigation.chat")}
|
||||
className={`transition-shadow ${
|
||||
hasScrolled ? "shadow-sm" : "shadow-none"
|
||||
}`}
|
||||
titleExtra={
|
||||
hasConfiguredModels && (
|
||||
<ModelSelector
|
||||
|
|
@ -90,7 +107,7 @@ export function ChatPage() {
|
|||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={newChat}
|
||||
className="h-9 gap-2"
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function ModelSelector({
|
|||
>
|
||||
<SelectValue placeholder={t("chat.noModel")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent position="popper" align="start">
|
||||
{apiKeyModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t("chat.modelGroup.apikey")}</SelectLabel>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function SessionHistoryMenu({
|
|||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9 gap-2">
|
||||
<Button variant="secondary" size="sm" className="h-9 gap-2">
|
||||
<IconHistory className="size-4" />
|
||||
<span className="hidden sm:inline">{t("chat.history")}</span>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export function EditModelSheet({
|
|||
: undefined,
|
||||
thinking_level: form.thinkingLevel || undefined,
|
||||
})
|
||||
if (setAsDefault) {
|
||||
if (setAsDefault && !model.is_default) {
|
||||
await setDefaultModel(model.model_name)
|
||||
}
|
||||
onSaved()
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ export function ModelsPage() {
|
|||
}, [fetchModels])
|
||||
|
||||
const handleSetDefault = async (model: ModelInfo) => {
|
||||
if (model.is_default) return
|
||||
|
||||
setSettingDefaultIndex(model.index)
|
||||
try {
|
||||
await setDefaultModel(model.model_name)
|
||||
|
|
|
|||
|
|
@ -2,16 +2,28 @@ import { IconMenu2 } from "@tabler/icons-react"
|
|||
import type { ReactNode } from "react"
|
||||
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
titleExtra?: ReactNode
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PageHeader({ title, titleExtra, children }: PageHeaderProps) {
|
||||
export function PageHeader({
|
||||
title,
|
||||
titleExtra,
|
||||
children,
|
||||
className,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex h-14 shrink-0 items-center justify-between px-6 pt-2">
|
||||
<div
|
||||
className={cn(
|
||||
"z-40 flex h-14 shrink-0 items-center justify-between px-6 pt-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<SidebarTrigger className="border-border/60 bg-background text-muted-foreground hover:bg-accent hover:text-foreground hidden h-9 w-9 rounded-lg border sm:flex [&>svg]:size-5">
|
||||
<IconMenu2 />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import { type ModelInfo, getModels, setDefaultModel } from "@/api/models"
|
||||
|
||||
|
|
@ -20,6 +20,7 @@ function isLocalModel(model: ModelInfo): boolean {
|
|||
export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
||||
const [modelList, setModelList] = useState<ModelInfo[]>([])
|
||||
const [defaultModelName, setDefaultModelName] = useState("")
|
||||
const setDefaultRequestIdRef = useRef(0)
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -41,17 +42,28 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
|||
return () => clearTimeout(timerId)
|
||||
}, [isConnected, loadModels])
|
||||
|
||||
const handleSetDefault = useCallback(async (modelName: string) => {
|
||||
const handleSetDefault = useCallback(
|
||||
async (modelName: string) => {
|
||||
if (modelName === defaultModelName) return
|
||||
const requestId = ++setDefaultRequestIdRef.current
|
||||
|
||||
try {
|
||||
await setDefaultModel(modelName)
|
||||
setDefaultModelName(modelName)
|
||||
setModelList((prev) =>
|
||||
prev.map((m) => ({ ...m, is_default: m.model_name === modelName })),
|
||||
)
|
||||
const data = await getModels()
|
||||
if (requestId !== setDefaultRequestIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setModelList(data.models)
|
||||
if (data.models.some((m) => m.model_name === data.default_model)) {
|
||||
setDefaultModelName(data.default_model)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to set default model:", err)
|
||||
}
|
||||
}, [])
|
||||
},
|
||||
[defaultModelName],
|
||||
)
|
||||
|
||||
const hasConfiguredModels = useMemo(
|
||||
() => modelList.some((m) => m.configured),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useAtomValue } from "jotai"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { clearGatewayLogs, getGatewayStatus } from "@/api/gateway"
|
||||
import { clearGatewayLogs, getGatewayLogs } from "@/api/gateway"
|
||||
import { gatewayAtom } from "@/store/gateway"
|
||||
|
||||
export function useGatewayLogs() {
|
||||
|
|
@ -37,7 +37,7 @@ export function useGatewayLogs() {
|
|||
const fetchLogs = async () => {
|
||||
if (
|
||||
!mounted ||
|
||||
(gateway.status !== "running" && gateway.status !== "starting")
|
||||
!["running", "starting", "restarting"].includes(gateway.status)
|
||||
) {
|
||||
if (mounted) {
|
||||
timeout = setTimeout(fetchLogs, 1000)
|
||||
|
|
@ -49,7 +49,7 @@ export function useGatewayLogs() {
|
|||
const requestToken = syncTokenRef.current
|
||||
const requestOffset = logOffsetRef.current
|
||||
const requestRunId = logRunIdRef.current
|
||||
const data = await getGatewayStatus({
|
||||
const data = await getGatewayLogs({
|
||||
log_offset: requestOffset,
|
||||
log_run_id: requestRunId,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,31 +1,30 @@
|
|||
import { useAtom } from "jotai"
|
||||
import { useAtomValue } from "jotai"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
import {
|
||||
type GatewayStatusResponse,
|
||||
getGatewayStatus,
|
||||
restartGateway,
|
||||
startGateway,
|
||||
stopGateway,
|
||||
} from "@/api/gateway"
|
||||
import { gatewayAtom } from "@/store"
|
||||
import {
|
||||
applyGatewayStatusToStore,
|
||||
gatewayAtom,
|
||||
updateGatewayStore,
|
||||
} from "@/store"
|
||||
|
||||
// Global variable to ensure we only have one SSE connection
|
||||
let sseInitialized = false
|
||||
|
||||
export function useGateway() {
|
||||
const [{ status: state, canStart }, setGateway] = useAtom(gatewayAtom)
|
||||
const gateway = useAtomValue(gatewayAtom)
|
||||
const { status: state, canStart, restartRequired } = gateway
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const applyGatewayStatus = useCallback(
|
||||
(data: GatewayStatusResponse) => {
|
||||
setGateway((prev) => ({
|
||||
...prev,
|
||||
status: data.gateway_status ?? "unknown",
|
||||
canStart: data.gateway_start_allowed ?? true,
|
||||
}))
|
||||
},
|
||||
[setGateway],
|
||||
)
|
||||
const applyGatewayStatus = useCallback((data: GatewayStatusResponse) => {
|
||||
applyGatewayStatusToStore(data)
|
||||
}, [])
|
||||
|
||||
// Initialize global SSE connection once
|
||||
useEffect(() => {
|
||||
|
|
@ -35,9 +34,10 @@ export function useGateway() {
|
|||
getGatewayStatus()
|
||||
.then((data) => applyGatewayStatus(data))
|
||||
.catch(() => {
|
||||
setGateway({
|
||||
updateGatewayStore({
|
||||
status: "unknown",
|
||||
canStart: true,
|
||||
restartRequired: false,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -59,14 +59,7 @@ export function useGateway() {
|
|||
data.gateway_status ||
|
||||
typeof data.gateway_start_allowed === "boolean"
|
||||
) {
|
||||
setGateway((prev) => ({
|
||||
...prev,
|
||||
status: data.gateway_status ?? prev.status,
|
||||
canStart:
|
||||
typeof data.gateway_start_allowed === "boolean"
|
||||
? data.gateway_start_allowed
|
||||
: prev.canStart,
|
||||
}))
|
||||
applyGatewayStatus(data)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
|
|
@ -75,7 +68,9 @@ export function useGateway() {
|
|||
|
||||
es.onerror = () => {
|
||||
// EventSource will auto-reconnect
|
||||
setGateway((prev) => ({ ...prev, status: "unknown" }))
|
||||
updateGatewayStore((prev) =>
|
||||
prev.status === "restarting" ? {} : { status: "unknown" },
|
||||
)
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
|
@ -83,7 +78,7 @@ export function useGateway() {
|
|||
es.close()
|
||||
sseInitialized = false
|
||||
}
|
||||
}, [applyGatewayStatus, setGateway])
|
||||
}, [applyGatewayStatus])
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!canStart) return
|
||||
|
|
@ -92,19 +87,19 @@ export function useGateway() {
|
|||
try {
|
||||
await startGateway()
|
||||
// SSE will push the real state changes, but set optimistic state
|
||||
setGateway((prev) => ({ ...prev, status: "starting" }))
|
||||
updateGatewayStore({ status: "starting" })
|
||||
} catch (err) {
|
||||
console.error("Failed to start gateway:", err)
|
||||
try {
|
||||
const status = await getGatewayStatus()
|
||||
applyGatewayStatus(status)
|
||||
} catch {
|
||||
setGateway((prev) => ({ ...prev, status: "unknown" }))
|
||||
updateGatewayStore({ status: "unknown" })
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [applyGatewayStatus, canStart, setGateway])
|
||||
}, [applyGatewayStatus, canStart])
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -117,5 +112,37 @@ export function useGateway() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
return { state, loading, canStart, start, stop }
|
||||
const restart = useCallback(async () => {
|
||||
if (state !== "running") return
|
||||
|
||||
const previousState = state
|
||||
const previousCanStart = canStart
|
||||
const previousRestartRequired = restartRequired
|
||||
|
||||
setLoading(true)
|
||||
updateGatewayStore({
|
||||
status: "restarting",
|
||||
restartRequired: false,
|
||||
})
|
||||
|
||||
try {
|
||||
await restartGateway()
|
||||
} catch (err) {
|
||||
console.error("Failed to restart gateway:", err)
|
||||
try {
|
||||
const status = await getGatewayStatus()
|
||||
applyGatewayStatus(status)
|
||||
} catch {
|
||||
updateGatewayStore({
|
||||
status: previousState,
|
||||
canStart: previousCanStart,
|
||||
restartRequired: previousRestartRequired,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [applyGatewayStatus, canStart, restartRequired, state])
|
||||
|
||||
return { state, loading, canStart, restartRequired, start, stop, restart }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +1,12 @@
|
|||
import dayjs from "dayjs"
|
||||
import { useAtomValue } from "jotai"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { getPicoToken } from "@/api/pico"
|
||||
import { getSessionHistory } from "@/api/sessions"
|
||||
import { gatewayAtom } from "@/store"
|
||||
|
||||
// Pico Protocol message types
|
||||
interface PicoMessage {
|
||||
type: string
|
||||
id?: string
|
||||
session_id?: string
|
||||
timestamp?: number | string
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
timestamp: number | string
|
||||
}
|
||||
|
||||
type ConnectionState = "disconnected" | "connecting" | "connected" | "error"
|
||||
|
||||
function generateSessionId(): string {
|
||||
const webCrypto = globalThis.crypto
|
||||
if (webCrypto && typeof webCrypto.randomUUID === "function") {
|
||||
return webCrypto.randomUUID()
|
||||
}
|
||||
|
||||
if (webCrypto && typeof webCrypto.getRandomValues === "function") {
|
||||
const bytes = new Uint8Array(16)
|
||||
webCrypto.getRandomValues(bytes)
|
||||
|
||||
// RFC4122 v4: set version and variant bits.
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"))
|
||||
return (
|
||||
`${hex[0]}${hex[1]}${hex[2]}${hex[3]}-` +
|
||||
`${hex[4]}${hex[5]}-` +
|
||||
`${hex[6]}${hex[7]}-` +
|
||||
`${hex[8]}${hex[9]}-` +
|
||||
`${hex[10]}${hex[11]}${hex[12]}${hex[13]}${hex[14]}${hex[15]}`
|
||||
)
|
||||
}
|
||||
|
||||
return `session-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
|
||||
}
|
||||
import {
|
||||
newChatSession,
|
||||
sendChatMessage,
|
||||
switchChatSession,
|
||||
} from "@/lib/pico-chat-controller"
|
||||
import { chatAtom } from "@/store/chat"
|
||||
|
||||
const UNIX_MS_THRESHOLD = 1e12
|
||||
|
||||
|
|
@ -102,285 +57,16 @@ export function formatMessageTime(dateRaw: number | string | Date): string {
|
|||
}
|
||||
|
||||
export function usePicoChat() {
|
||||
const { t } = useTranslation()
|
||||
const { status: gatewayState } = useAtomValue(gatewayAtom)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [connectionState, setConnectionState] =
|
||||
useState<ConnectionState>("disconnected")
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
const [activeSessionId, setActiveSessionId] =
|
||||
useState<string>(generateSessionId)
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const isConnectingRef = useRef(false)
|
||||
const msgIdCounter = useRef(0)
|
||||
const activeSessionIdRef = useRef(activeSessionId)
|
||||
|
||||
// Keep ref in sync
|
||||
useEffect(() => {
|
||||
activeSessionIdRef.current = activeSessionId
|
||||
}, [activeSessionId])
|
||||
|
||||
const handlePicoMessage = useCallback((msg: PicoMessage) => {
|
||||
const payload = msg.payload || {}
|
||||
|
||||
switch (msg.type) {
|
||||
case "message.create": {
|
||||
const content = (payload.content as string) || ""
|
||||
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
|
||||
// Use provided timestamp or current time
|
||||
const timestampRaw =
|
||||
msg.timestamp !== undefined && Number.isFinite(Number(msg.timestamp))
|
||||
? normalizeUnixTimestamp(Number(msg.timestamp))
|
||||
: Date.now()
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: messageId,
|
||||
role: "assistant",
|
||||
content,
|
||||
timestamp: timestampRaw,
|
||||
},
|
||||
])
|
||||
setIsTyping(false)
|
||||
break
|
||||
}
|
||||
|
||||
case "message.update": {
|
||||
const content = (payload.content as string) || ""
|
||||
const messageId = payload.message_id as string
|
||||
if (!messageId) break
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === messageId ? { ...m, content } : m)),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "typing.start":
|
||||
setIsTyping(true)
|
||||
break
|
||||
|
||||
case "typing.stop":
|
||||
setIsTyping(false)
|
||||
break
|
||||
|
||||
case "error":
|
||||
console.error("Pico error:", payload)
|
||||
setIsTyping(false)
|
||||
break
|
||||
|
||||
case "pong":
|
||||
// heartbeat response, ignore
|
||||
break
|
||||
|
||||
default:
|
||||
console.log("Unknown pico message type:", msg.type)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
if (
|
||||
isConnectingRef.current ||
|
||||
(wsRef.current &&
|
||||
(wsRef.current.readyState === WebSocket.OPEN ||
|
||||
wsRef.current.readyState === WebSocket.CONNECTING))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
isConnectingRef.current = true
|
||||
setConnectionState("connecting")
|
||||
|
||||
try {
|
||||
const { token, ws_url } = await getPicoToken()
|
||||
|
||||
if (!token) {
|
||||
console.error("No pico token available")
|
||||
setConnectionState("error")
|
||||
isConnectingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
// If the backend returns a localhost URL but we are accessing it via a LAN IP
|
||||
// (e.g., from a mobile device during dev), rewrite the hostname to match.
|
||||
let finalWsUrl = ws_url
|
||||
try {
|
||||
const parsedUrl = new URL(ws_url)
|
||||
const isLocalHost =
|
||||
parsedUrl.hostname === "localhost" ||
|
||||
parsedUrl.hostname === "127.0.0.1" ||
|
||||
parsedUrl.hostname === "0.0.0.0"
|
||||
const isBrowserLocal =
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1"
|
||||
|
||||
if (isLocalHost && !isBrowserLocal) {
|
||||
parsedUrl.hostname = window.location.hostname
|
||||
finalWsUrl = parsedUrl.toString()
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Could not parse ws_url:", e)
|
||||
}
|
||||
|
||||
// Build WebSocket URL with session_id
|
||||
const sessionId = activeSessionIdRef.current
|
||||
const url = `${finalWsUrl}?token=${encodeURIComponent(token)}&session_id=${encodeURIComponent(sessionId)}`
|
||||
const socket = new WebSocket(url)
|
||||
|
||||
socket.onopen = () => {
|
||||
setConnectionState("connected")
|
||||
isConnectingRef.current = false
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const msg: PicoMessage = JSON.parse(event.data)
|
||||
handlePicoMessage(msg)
|
||||
} catch {
|
||||
console.warn("Non-JSON message from pico:", event.data)
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
setConnectionState("disconnected")
|
||||
wsRef.current = null
|
||||
isConnectingRef.current = false
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
setConnectionState("error")
|
||||
isConnectingRef.current = false
|
||||
}
|
||||
|
||||
wsRef.current = socket
|
||||
} catch (err) {
|
||||
console.error("Failed to connect to pico:", err)
|
||||
setConnectionState("error")
|
||||
isConnectingRef.current = false
|
||||
}
|
||||
}, [handlePicoMessage])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
setConnectionState("disconnected")
|
||||
isConnectingRef.current = false
|
||||
}, [])
|
||||
|
||||
// Auto connect/disconnect based on gateway state
|
||||
useEffect(() => {
|
||||
// Wrap in setTimeout to avoid React calling setState synchronously during render
|
||||
const timerId = setTimeout(() => {
|
||||
if (gatewayState === "running") {
|
||||
connect()
|
||||
} else {
|
||||
disconnect()
|
||||
}
|
||||
}, 0)
|
||||
|
||||
return () => clearTimeout(timerId)
|
||||
}, [gatewayState, connect, disconnect])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => disconnect()
|
||||
}, [disconnect])
|
||||
|
||||
const sendMessage = useCallback((content: string) => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||
console.warn("WebSocket not connected")
|
||||
return
|
||||
}
|
||||
|
||||
const id = `msg-${++msgIdCounter.current}-${Date.now()}`
|
||||
const timestampRaw = Date.now()
|
||||
|
||||
// Add user message to local state
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id, role: "user", content, timestamp: timestampRaw },
|
||||
])
|
||||
|
||||
// Show typing indicator immediately
|
||||
setIsTyping(true)
|
||||
|
||||
// Send via Pico Protocol
|
||||
const picoMsg: PicoMessage = {
|
||||
type: "message.send",
|
||||
id,
|
||||
payload: { content },
|
||||
}
|
||||
wsRef.current.send(JSON.stringify(picoMsg))
|
||||
}, [])
|
||||
|
||||
// Switch to a historical session
|
||||
const switchSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (sessionId === activeSessionIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await getSessionHistory(sessionId)
|
||||
const fallbackTime = detail.updated
|
||||
const historyMessages = detail.messages.map((m, i) => ({
|
||||
id: `hist-${i}-${Date.now()}`,
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content,
|
||||
timestamp: fallbackTime,
|
||||
}))
|
||||
|
||||
// Only switch the active websocket session after history has loaded successfully.
|
||||
disconnect()
|
||||
setActiveSessionId(sessionId)
|
||||
setIsTyping(false)
|
||||
setMessages(historyMessages)
|
||||
} catch (err) {
|
||||
console.error("Failed to load session history:", err)
|
||||
toast.error(t("chat.historyOpenFailed"))
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (gatewayState === "running") {
|
||||
connect()
|
||||
}
|
||||
}, 100)
|
||||
},
|
||||
[connect, disconnect, gatewayState, t],
|
||||
)
|
||||
|
||||
// Start a new empty chat
|
||||
const newChat = useCallback(() => {
|
||||
if (messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
disconnect()
|
||||
const newId = generateSessionId()
|
||||
setActiveSessionId(newId)
|
||||
setMessages([])
|
||||
setIsTyping(false)
|
||||
|
||||
// Reconnect with the fresh session
|
||||
setTimeout(() => {
|
||||
if (gatewayState === "running") {
|
||||
connect()
|
||||
}
|
||||
}, 100)
|
||||
}, [disconnect, connect, gatewayState, messages.length])
|
||||
const { messages, connectionState, isTyping, activeSessionId } =
|
||||
useAtomValue(chatAtom)
|
||||
|
||||
return {
|
||||
messages,
|
||||
connectionState,
|
||||
isTyping,
|
||||
activeSessionId,
|
||||
sendMessage,
|
||||
switchSession,
|
||||
newChat,
|
||||
sendMessage: sendChatMessage,
|
||||
switchSession: switchChatSession,
|
||||
newChat: newChatSession,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,11 +58,14 @@
|
|||
},
|
||||
"action": {
|
||||
"start": "Start Gateway",
|
||||
"stop": "Stop Gateway"
|
||||
"stop": "Stop Gateway",
|
||||
"restart": "Restart Gateway"
|
||||
},
|
||||
"status": {
|
||||
"starting": "Starting Gateway..."
|
||||
}
|
||||
"starting": "Starting Gateway...",
|
||||
"restarting": "Restarting Gateway..."
|
||||
},
|
||||
"restartRequired": "Model changes require a gateway restart to take effect."
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
|
|
|
|||
|
|
@ -58,11 +58,14 @@
|
|||
},
|
||||
"action": {
|
||||
"start": "启动服务",
|
||||
"stop": "停止服务"
|
||||
"stop": "停止服务",
|
||||
"restart": "重启服务"
|
||||
},
|
||||
"status": {
|
||||
"starting": "服务启动中..."
|
||||
}
|
||||
"starting": "服务启动中...",
|
||||
"restarting": "服务重启中..."
|
||||
},
|
||||
"restartRequired": "切换默认模型后需要重启服务才能生效。"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
|
|
|
|||
406
web/frontend/src/lib/pico-chat-controller.ts
Normal file
406
web/frontend/src/lib/pico-chat-controller.ts
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
import { getDefaultStore } from "jotai"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { getPicoToken } from "@/api/pico"
|
||||
import { getSessionHistory } from "@/api/sessions"
|
||||
import i18n from "@/i18n"
|
||||
import {
|
||||
clearStoredSessionId,
|
||||
generateSessionId,
|
||||
normalizeUnixTimestamp,
|
||||
readStoredSessionId,
|
||||
} from "@/lib/pico-chat-state"
|
||||
import { type ChatMessage, getChatState, updateChatStore } from "@/store/chat"
|
||||
import { gatewayAtom } from "@/store/gateway"
|
||||
|
||||
interface PicoMessage {
|
||||
type: string
|
||||
id?: string
|
||||
session_id?: string
|
||||
timestamp?: number | string
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const store = getDefaultStore()
|
||||
|
||||
let wsRef: WebSocket | null = null
|
||||
let isConnecting = false
|
||||
let msgIdCounter = 0
|
||||
let activeSessionIdRef = getChatState().activeSessionId
|
||||
let initialized = false
|
||||
let unsubscribeGateway: (() => void) | null = null
|
||||
let hydratePromise: Promise<void> | null = null
|
||||
let connectionGeneration = 0
|
||||
|
||||
async function loadSessionMessages(sessionId: string): Promise<ChatMessage[]> {
|
||||
const detail = await getSessionHistory(sessionId)
|
||||
const fallbackTime = detail.updated
|
||||
|
||||
return detail.messages.map((message, index) => ({
|
||||
id: `hist-${index}-${Date.now()}`,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
timestamp: fallbackTime,
|
||||
}))
|
||||
}
|
||||
|
||||
function handlePicoMessage(message: PicoMessage) {
|
||||
const payload = message.payload || {}
|
||||
|
||||
switch (message.type) {
|
||||
case "message.create": {
|
||||
const content = (payload.content as string) || ""
|
||||
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
|
||||
const timestamp =
|
||||
message.timestamp !== undefined &&
|
||||
Number.isFinite(Number(message.timestamp))
|
||||
? normalizeUnixTimestamp(Number(message.timestamp))
|
||||
: Date.now()
|
||||
|
||||
updateChatStore((prev) => ({
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{
|
||||
id: messageId,
|
||||
role: "assistant",
|
||||
content,
|
||||
timestamp,
|
||||
},
|
||||
],
|
||||
isTyping: false,
|
||||
}))
|
||||
break
|
||||
}
|
||||
|
||||
case "message.update": {
|
||||
const content = (payload.content as string) || ""
|
||||
const messageId = payload.message_id as string
|
||||
if (!messageId) {
|
||||
break
|
||||
}
|
||||
|
||||
updateChatStore((prev) => ({
|
||||
messages: prev.messages.map((msg) =>
|
||||
msg.id === messageId ? { ...msg, content } : msg,
|
||||
),
|
||||
}))
|
||||
break
|
||||
}
|
||||
|
||||
case "typing.start":
|
||||
updateChatStore({ isTyping: true })
|
||||
break
|
||||
|
||||
case "typing.stop":
|
||||
updateChatStore({ isTyping: false })
|
||||
break
|
||||
|
||||
case "error":
|
||||
console.error("Pico error:", payload)
|
||||
updateChatStore({ isTyping: false })
|
||||
break
|
||||
|
||||
case "pong":
|
||||
break
|
||||
|
||||
default:
|
||||
console.log("Unknown pico message type:", message.type)
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveSessionId(sessionId: string) {
|
||||
activeSessionIdRef = sessionId
|
||||
updateChatStore({ activeSessionId: sessionId })
|
||||
}
|
||||
|
||||
export async function connectChat() {
|
||||
if (store.get(gatewayAtom).status !== "running") {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
isConnecting ||
|
||||
(wsRef &&
|
||||
(wsRef.readyState === WebSocket.OPEN ||
|
||||
wsRef.readyState === WebSocket.CONNECTING))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const generation = connectionGeneration + 1
|
||||
connectionGeneration = generation
|
||||
isConnecting = true
|
||||
updateChatStore({ connectionState: "connecting" })
|
||||
|
||||
try {
|
||||
const { token, ws_url } = await getPicoToken()
|
||||
|
||||
if (generation !== connectionGeneration) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
console.error("No pico token available")
|
||||
updateChatStore({ connectionState: "error" })
|
||||
isConnecting = false
|
||||
return
|
||||
}
|
||||
|
||||
let finalWsUrl = ws_url
|
||||
try {
|
||||
const parsedUrl = new URL(ws_url)
|
||||
const isLocalHost =
|
||||
parsedUrl.hostname === "localhost" ||
|
||||
parsedUrl.hostname === "127.0.0.1" ||
|
||||
parsedUrl.hostname === "0.0.0.0"
|
||||
const isBrowserLocal =
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1"
|
||||
|
||||
if (isLocalHost && !isBrowserLocal) {
|
||||
parsedUrl.hostname = window.location.hostname
|
||||
finalWsUrl = parsedUrl.toString()
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not parse ws_url:", error)
|
||||
}
|
||||
|
||||
const url = `${finalWsUrl}?session_id=${encodeURIComponent(activeSessionIdRef)}`
|
||||
// Send token as a subprotocol so it doesn't end up in the URL.
|
||||
const socket = new WebSocket(url, [`token.${token}`])
|
||||
|
||||
if (generation !== connectionGeneration) {
|
||||
socket.close()
|
||||
return
|
||||
}
|
||||
|
||||
socket.onopen = () => {
|
||||
if (wsRef !== socket) {
|
||||
return
|
||||
}
|
||||
updateChatStore({ connectionState: "connected" })
|
||||
isConnecting = false
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const message: PicoMessage = JSON.parse(event.data)
|
||||
handlePicoMessage(message)
|
||||
} catch {
|
||||
console.warn("Non-JSON message from pico:", event.data)
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
if (wsRef !== socket) {
|
||||
return
|
||||
}
|
||||
wsRef = null
|
||||
isConnecting = false
|
||||
updateChatStore({
|
||||
connectionState: "disconnected",
|
||||
isTyping: false,
|
||||
})
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
if (wsRef !== socket) {
|
||||
return
|
||||
}
|
||||
isConnecting = false
|
||||
updateChatStore({ connectionState: "error" })
|
||||
}
|
||||
|
||||
wsRef = socket
|
||||
} catch (error) {
|
||||
if (generation !== connectionGeneration) {
|
||||
return
|
||||
}
|
||||
console.error("Failed to connect to pico:", error)
|
||||
updateChatStore({ connectionState: "error" })
|
||||
isConnecting = false
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectChat() {
|
||||
connectionGeneration += 1
|
||||
|
||||
const socket = wsRef
|
||||
wsRef = null
|
||||
isConnecting = false
|
||||
|
||||
if (socket) {
|
||||
socket.close()
|
||||
}
|
||||
|
||||
updateChatStore({
|
||||
connectionState: "disconnected",
|
||||
isTyping: false,
|
||||
})
|
||||
}
|
||||
|
||||
export async function hydrateActiveSession() {
|
||||
if (hydratePromise) {
|
||||
return hydratePromise
|
||||
}
|
||||
|
||||
const state = getChatState()
|
||||
const storedSessionId = readStoredSessionId()
|
||||
|
||||
if (
|
||||
!storedSessionId ||
|
||||
state.hasHydratedActiveSession ||
|
||||
state.messages.length > 0 ||
|
||||
storedSessionId !== state.activeSessionId
|
||||
) {
|
||||
if (!state.hasHydratedActiveSession) {
|
||||
updateChatStore({ hasHydratedActiveSession: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
hydratePromise = loadSessionMessages(storedSessionId)
|
||||
.then((historyMessages) => {
|
||||
const currentState = getChatState()
|
||||
if (currentState.activeSessionId !== storedSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentState.messages.length > 0) {
|
||||
updateChatStore({ hasHydratedActiveSession: true })
|
||||
return
|
||||
}
|
||||
|
||||
updateChatStore({
|
||||
messages: historyMessages,
|
||||
isTyping: false,
|
||||
hasHydratedActiveSession: true,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to restore last session history:", error)
|
||||
|
||||
const currentState = getChatState()
|
||||
if (currentState.activeSessionId !== storedSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentState.messages.length > 0) {
|
||||
updateChatStore({ hasHydratedActiveSession: true })
|
||||
return
|
||||
}
|
||||
|
||||
clearStoredSessionId()
|
||||
updateChatStore({
|
||||
messages: [],
|
||||
isTyping: false,
|
||||
hasHydratedActiveSession: true,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
hydratePromise = null
|
||||
})
|
||||
|
||||
return hydratePromise
|
||||
}
|
||||
|
||||
export function sendChatMessage(content: string) {
|
||||
if (!wsRef || wsRef.readyState !== WebSocket.OPEN) {
|
||||
console.warn("WebSocket not connected")
|
||||
return
|
||||
}
|
||||
|
||||
const id = `msg-${++msgIdCounter}-${Date.now()}`
|
||||
|
||||
updateChatStore((prev) => ({
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{ id, role: "user", content, timestamp: Date.now() },
|
||||
],
|
||||
isTyping: true,
|
||||
}))
|
||||
|
||||
wsRef.send(
|
||||
JSON.stringify({
|
||||
type: "message.send",
|
||||
id,
|
||||
payload: { content },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function switchChatSession(sessionId: string) {
|
||||
if (sessionId === activeSessionIdRef) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const historyMessages = await loadSessionMessages(sessionId)
|
||||
|
||||
disconnectChat()
|
||||
setActiveSessionId(sessionId)
|
||||
updateChatStore({
|
||||
messages: historyMessages,
|
||||
isTyping: false,
|
||||
hasHydratedActiveSession: true,
|
||||
})
|
||||
|
||||
if (store.get(gatewayAtom).status === "running") {
|
||||
await connectChat()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load session history:", error)
|
||||
toast.error(i18n.t("chat.historyOpenFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
export async function newChatSession() {
|
||||
if (getChatState().messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
disconnectChat()
|
||||
setActiveSessionId(generateSessionId())
|
||||
updateChatStore({
|
||||
messages: [],
|
||||
isTyping: false,
|
||||
hasHydratedActiveSession: true,
|
||||
})
|
||||
|
||||
if (store.get(gatewayAtom).status === "running") {
|
||||
await connectChat()
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeChatStore() {
|
||||
if (initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
initialized = true
|
||||
activeSessionIdRef = getChatState().activeSessionId
|
||||
|
||||
const syncConnectionWithGateway = () => {
|
||||
if (store.get(gatewayAtom).status === "running") {
|
||||
void connectChat()
|
||||
return
|
||||
}
|
||||
|
||||
disconnectChat()
|
||||
}
|
||||
|
||||
unsubscribeGateway = store.sub(gatewayAtom, syncConnectionWithGateway)
|
||||
|
||||
if (!readStoredSessionId()) {
|
||||
updateChatStore({ hasHydratedActiveSession: true })
|
||||
}
|
||||
|
||||
syncConnectionWithGateway()
|
||||
}
|
||||
|
||||
export function teardownChatStore() {
|
||||
unsubscribeGateway?.()
|
||||
unsubscribeGateway = null
|
||||
initialized = false
|
||||
disconnectChat()
|
||||
}
|
||||
59
web/frontend/src/lib/pico-chat-state.ts
Normal file
59
web/frontend/src/lib/pico-chat-state.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
const LAST_SESSION_STORAGE_KEY = "picoclaw:last-session-id"
|
||||
const UNIX_MS_THRESHOLD = 1e12
|
||||
|
||||
function readStorageValue() {
|
||||
return (
|
||||
globalThis.localStorage?.getItem(LAST_SESSION_STORAGE_KEY)?.trim() || ""
|
||||
)
|
||||
}
|
||||
|
||||
export function readStoredSessionId(): string {
|
||||
return readStorageValue()
|
||||
}
|
||||
|
||||
export function writeStoredSessionId(sessionId: string) {
|
||||
if (sessionId) {
|
||||
globalThis.localStorage?.setItem(LAST_SESSION_STORAGE_KEY, sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
globalThis.localStorage?.removeItem(LAST_SESSION_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export function clearStoredSessionId() {
|
||||
globalThis.localStorage?.removeItem(LAST_SESSION_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export function generateSessionId(): string {
|
||||
const webCrypto = globalThis.crypto
|
||||
if (webCrypto && typeof webCrypto.randomUUID === "function") {
|
||||
return webCrypto.randomUUID()
|
||||
}
|
||||
|
||||
if (webCrypto && typeof webCrypto.getRandomValues === "function") {
|
||||
const bytes = new Uint8Array(16)
|
||||
webCrypto.getRandomValues(bytes)
|
||||
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"))
|
||||
return (
|
||||
`${hex[0]}${hex[1]}${hex[2]}${hex[3]}-` +
|
||||
`${hex[4]}${hex[5]}-` +
|
||||
`${hex[6]}${hex[7]}-` +
|
||||
`${hex[8]}${hex[9]}-` +
|
||||
`${hex[10]}${hex[11]}${hex[12]}${hex[13]}${hex[14]}${hex[15]}`
|
||||
)
|
||||
}
|
||||
|
||||
return `session-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
|
||||
}
|
||||
|
||||
export function getInitialActiveSessionId(): string {
|
||||
return readStorageValue() || generateSessionId()
|
||||
}
|
||||
|
||||
export function normalizeUnixTimestamp(timestamp: number): number {
|
||||
return timestamp < UNIX_MS_THRESHOLD ? timestamp * 1000 : timestamp
|
||||
}
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
import { Outlet, createRootRoute } from "@tanstack/react-router"
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { AppLayout } from "@/components/app-layout"
|
||||
import { initializeChatStore } from "@/lib/pico-chat-controller"
|
||||
|
||||
const RootLayout = () => {
|
||||
useEffect(() => {
|
||||
initializeChatStore()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Outlet />
|
||||
|
|
|
|||
62
web/frontend/src/store/chat.ts
Normal file
62
web/frontend/src/store/chat.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { atom, getDefaultStore } from "jotai"
|
||||
|
||||
import {
|
||||
getInitialActiveSessionId,
|
||||
writeStoredSessionId,
|
||||
} from "@/lib/pico-chat-state"
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
timestamp: number | string
|
||||
}
|
||||
|
||||
export type ConnectionState =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "error"
|
||||
|
||||
export interface ChatStoreState {
|
||||
messages: ChatMessage[]
|
||||
connectionState: ConnectionState
|
||||
isTyping: boolean
|
||||
activeSessionId: string
|
||||
hasHydratedActiveSession: boolean
|
||||
}
|
||||
|
||||
type ChatStorePatch = Partial<ChatStoreState>
|
||||
|
||||
const DEFAULT_CHAT_STATE: ChatStoreState = {
|
||||
messages: [],
|
||||
connectionState: "disconnected",
|
||||
isTyping: false,
|
||||
activeSessionId: getInitialActiveSessionId(),
|
||||
hasHydratedActiveSession: false,
|
||||
}
|
||||
|
||||
export const chatAtom = atom<ChatStoreState>(DEFAULT_CHAT_STATE)
|
||||
|
||||
const store = getDefaultStore()
|
||||
|
||||
export function getChatState() {
|
||||
return store.get(chatAtom)
|
||||
}
|
||||
|
||||
export function updateChatStore(
|
||||
patch:
|
||||
| ChatStorePatch
|
||||
| ((prev: ChatStoreState) => ChatStorePatch | ChatStoreState),
|
||||
) {
|
||||
store.set(chatAtom, (prev) => {
|
||||
const nextPatch = typeof patch === "function" ? patch(prev) : patch
|
||||
const next = { ...prev, ...nextPatch }
|
||||
|
||||
if (next.activeSessionId !== prev.activeSessionId) {
|
||||
writeStoredSessionId(next.activeSessionId)
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { type GatewayStatusResponse, getGatewayStatus } from "@/api/gateway"
|
|||
export type GatewayState =
|
||||
| "running"
|
||||
| "starting"
|
||||
| "restarting"
|
||||
| "stopped"
|
||||
| "error"
|
||||
| "unknown"
|
||||
|
|
@ -12,19 +13,54 @@ export type GatewayState =
|
|||
export interface GatewayStoreState {
|
||||
status: GatewayState
|
||||
canStart: boolean
|
||||
restartRequired: boolean
|
||||
}
|
||||
|
||||
type GatewayStorePatch = Partial<GatewayStoreState>
|
||||
|
||||
const DEFAULT_GATEWAY_STATE: GatewayStoreState = {
|
||||
status: "unknown",
|
||||
canStart: true,
|
||||
restartRequired: false,
|
||||
}
|
||||
|
||||
// Global atom for gateway state
|
||||
export const gatewayAtom = atom<GatewayStoreState>({
|
||||
status: "unknown",
|
||||
canStart: true,
|
||||
})
|
||||
export const gatewayAtom = atom<GatewayStoreState>(DEFAULT_GATEWAY_STATE)
|
||||
|
||||
function applyGatewayStatusToStore(data: GatewayStatusResponse) {
|
||||
getDefaultStore().set(gatewayAtom, (prev) => ({
|
||||
...prev,
|
||||
status: data.gateway_status ?? "unknown",
|
||||
canStart: data.gateway_start_allowed ?? true,
|
||||
function normalizeGatewayStoreState(
|
||||
prev: GatewayStoreState,
|
||||
patch: GatewayStorePatch,
|
||||
) {
|
||||
return { ...prev, ...patch }
|
||||
}
|
||||
|
||||
export function updateGatewayStore(
|
||||
patch:
|
||||
| GatewayStorePatch
|
||||
| ((prev: GatewayStoreState) => GatewayStorePatch | GatewayStoreState),
|
||||
) {
|
||||
getDefaultStore().set(gatewayAtom, (prev) => {
|
||||
const nextPatch = typeof patch === "function" ? patch(prev) : patch
|
||||
return normalizeGatewayStoreState(prev, nextPatch)
|
||||
})
|
||||
}
|
||||
|
||||
export function applyGatewayStatusToStore(
|
||||
data: Partial<
|
||||
Pick<
|
||||
GatewayStatusResponse,
|
||||
"gateway_status" | "gateway_start_allowed" | "gateway_restart_required"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
updateGatewayStore((prev) => ({
|
||||
status: data.gateway_status ?? prev.status,
|
||||
canStart: data.gateway_start_allowed ?? prev.canStart,
|
||||
restartRequired:
|
||||
data.gateway_restart_required ??
|
||||
(data.gateway_status && data.gateway_status !== "running"
|
||||
? false
|
||||
: prev.restartRequired),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +69,6 @@ export async function refreshGatewayState() {
|
|||
const status = await getGatewayStatus()
|
||||
applyGatewayStatusToStore(status)
|
||||
} catch {
|
||||
// Best-effort refresh only; keep current state on error.
|
||||
updateGatewayStore(DEFAULT_GATEWAY_STATE)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export * from "./gateway"
|
||||
export * from "./chat"
|
||||
|
|
|
|||
|
|
@ -1,49 +1,59 @@
|
|||
---
|
||||
name: weather
|
||||
description: Get current weather and forecasts (no API key required).
|
||||
description: Get current weather and forecasts with verified location matching (no API key required).
|
||||
homepage: https://wttr.in/:help
|
||||
metadata: {"nanobot":{"emoji":"🌤️","requires":{"bins":["curl"]}}}
|
||||
---
|
||||
|
||||
# Weather
|
||||
|
||||
Two free services, no API keys needed.
|
||||
Use the most reliable location match first. For Chinese city names or other non-Latin input, prefer `wttr.in` with the original query because it resolves native names directly. Use Open-Meteo for structured current conditions and forecasts only after you have confirmed the exact city.
|
||||
|
||||
## wttr.in (primary)
|
||||
## Accuracy Rules
|
||||
|
||||
Quick one-liner:
|
||||
- Always restate the matched location, region/country, and observation time in the final answer.
|
||||
- Do not trust the first geocoding hit blindly. Check `country`, `admin1`, `admin2`, and `population`.
|
||||
- For Chinese city queries, do not send Hanzi directly to Open-Meteo geocoding unless the top result is obviously correct. Prefer `wttr.in` with the original Chinese name, or geocode the English/pinyin city name instead.
|
||||
- If multiple plausible matches remain, ask a follow-up question or state the assumption clearly.
|
||||
- Use `timezone=auto` when calling Open-Meteo so the reported time matches the location.
|
||||
|
||||
## wttr.in (best for direct city-name queries)
|
||||
|
||||
Quick current conditions:
|
||||
```bash
|
||||
curl -s "wttr.in/London?format=3"
|
||||
# Output: London: ⛅️ +8°C
|
||||
curl -s "https://wttr.in/London?format=%l:+%c+%t+%h+%w"
|
||||
```
|
||||
|
||||
Compact format:
|
||||
Chinese city example:
|
||||
```bash
|
||||
curl -s "wttr.in/London?format=%l:+%c+%t+%h+%w"
|
||||
# Output: London: ⛅️ +8°C 71% ↙5km/h
|
||||
curl -s "https://wttr.in/%E6%88%90%E9%83%BD?format=%l:+%c+%t+%h+%w"
|
||||
curl -s "https://wttr.in/%E4%B8%8A%E6%B5%B7?format=%l:+%c+%t+%h+%w"
|
||||
```
|
||||
|
||||
Full forecast:
|
||||
JSON output if you need more detail:
|
||||
```bash
|
||||
curl -s "wttr.in/London?T"
|
||||
curl -s "https://wttr.in/Chengdu?format=j1"
|
||||
```
|
||||
|
||||
Format codes: `%c` condition · `%t` temp · `%h` humidity · `%w` wind · `%l` location · `%m` moon
|
||||
|
||||
Tips:
|
||||
- URL-encode spaces: `wttr.in/New+York`
|
||||
- Airport codes: `wttr.in/JFK`
|
||||
- Units: `?m` (metric) `?u` (USCS)
|
||||
- Today only: `?1` · Current only: `?0`
|
||||
- PNG: `curl -s "wttr.in/Berlin.png" -o /tmp/weather.png`
|
||||
- URL-encode spaces: `New York` -> `New+York`
|
||||
- URL-encode non-ASCII text before sending the request
|
||||
- Use `?m` for metric units and `?u` for US units
|
||||
|
||||
## Open-Meteo (fallback, JSON)
|
||||
## Open-Meteo (best for structured forecasts)
|
||||
|
||||
Free, no key, good for programmatic use:
|
||||
1. Geocode the city and verify the returned location metadata:
|
||||
```bash
|
||||
curl -s "https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.12¤t_weather=true"
|
||||
curl -s "https://geocoding-api.open-meteo.com/v1/search?name=Chengdu&count=3&language=en&format=json"
|
||||
```
|
||||
|
||||
Find coordinates for a city, then query. Returns JSON with temp, windspeed, weathercode.
|
||||
2. Query current weather and today's forecast with the verified coordinates:
|
||||
```bash
|
||||
curl -s "https://api.open-meteo.com/v1/forecast?latitude=30.66667&longitude=104.06667¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min&forecast_days=1&timezone=auto"
|
||||
```
|
||||
|
||||
Important:
|
||||
- For Chinese inputs like `成都`, geocoding `name=%E6%88%90%E9%83%BD` may return smaller homonym locations first. Prefer `Chengdu` after verifying it matches Sichuan, China.
|
||||
- If geocoding looks suspicious, fall back to `wttr.in` for the original city name instead of presenting a likely wrong result.
|
||||
|
||||
Docs: https://open-meteo.com/en/docs
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue