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
|
# ANTHROPIC_API_KEY=sk-ant-xxx
|
||||||
# OPENAI_API_KEY=sk-xxx
|
# OPENAI_API_KEY=sk-xxx
|
||||||
# GEMINI_API_KEY=xxx
|
# GEMINI_API_KEY=xxx
|
||||||
|
# MODELSCOPE_API_KEY=xxx
|
||||||
# CLAUDE_CODE_OAUTH=xxx
|
# CLAUDE_CODE_OAUTH=xxx
|
||||||
# ── Chat Channel ──────────────────────────
|
# ── Chat Channel ──────────────────────────
|
||||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
# 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
|
- windows
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
- freebsd
|
||||||
|
- netbsd
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
|
|
@ -44,6 +45,12 @@ builds:
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
- goos: netbsd
|
||||||
|
goarch: s390x
|
||||||
|
- goos: netbsd
|
||||||
|
goarch: mips64
|
||||||
|
- goos: netbsd
|
||||||
|
goarch: arm
|
||||||
|
|
||||||
- id: picoclaw-launcher
|
- id: picoclaw-launcher
|
||||||
binary: picoclaw-launcher
|
binary: picoclaw-launcher
|
||||||
|
|
@ -58,6 +65,7 @@ builds:
|
||||||
- windows
|
- windows
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
- freebsd
|
||||||
|
- netbsd
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
|
|
@ -75,6 +83,12 @@ builds:
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
- goos: netbsd
|
||||||
|
goarch: s390x
|
||||||
|
- goos: netbsd
|
||||||
|
goarch: mips64
|
||||||
|
- goos: netbsd
|
||||||
|
goarch: arm
|
||||||
|
|
||||||
- id: picoclaw-launcher-tui
|
- id: picoclaw-launcher-tui
|
||||||
binary: picoclaw-launcher-tui
|
binary: picoclaw-launcher-tui
|
||||||
|
|
@ -89,6 +103,7 @@ builds:
|
||||||
- windows
|
- windows
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
- freebsd
|
||||||
|
- netbsd
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
|
|
@ -106,6 +121,12 @@ builds:
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
- goos: netbsd
|
||||||
|
goarch: s390x
|
||||||
|
- goos: netbsd
|
||||||
|
goarch: mips64
|
||||||
|
- goos: netbsd
|
||||||
|
goarch: arm
|
||||||
|
|
||||||
dockers_v2:
|
dockers_v2:
|
||||||
- id: picoclaw
|
- 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=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=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=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"
|
@echo "All builds complete"
|
||||||
|
|
||||||
## install: Install picoclaw to system and copy builtin skills
|
## 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>
|
<h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1>
|
||||||
|
|
||||||
<h3>Matériel à 10$ · 10 Mo de RAM · Démarrage en 1s · 皮皮虾,我们走!</h3>
|
<h3>Matériel à 10$ · 10 Mo de RAM · Démarrage en 1s · 皮皮虾,我们走!</h3>
|
||||||
|
|
||||||
<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/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/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">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<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://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="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>
|
</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**
|
[中文](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
|
### 🚀 Démarrage Rapide
|
||||||
|
|
||||||
> [!TIP]
|
> [!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**
|
**1. Initialiser**
|
||||||
|
|
||||||
|
|
@ -222,7 +226,8 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key"
|
"api_key": "sk-your-api-key",
|
||||||
|
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"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) |
|
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
|
| `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) |
|
| `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) |
|
| `anthropic` (À tester) | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai` (À tester) | LLM (GPT direct) | [platform.openai.com](https://platform.openai.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) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) |
|
| **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 | - |
|
| **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) |
|
| **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 |
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **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 |
|
| **Zhipu** | 200K tokens/mois | Convient aux utilisateurs chinois |
|
||||||
| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web |
|
| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web |
|
||||||
| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) |
|
| **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>$10 ハードウェア · 10MB RAM · 1秒起動 · 行くぜ、シャコ!</h3>
|
||||||
<h3></h3>
|
<h3></h3>
|
||||||
|
<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/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/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">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<br>
|
||||||
</p>
|
<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)
|
[中文](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]
|
> [!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. 初期化**
|
**1. 初期化**
|
||||||
|
|
||||||
|
|
@ -184,7 +191,8 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key"
|
"api_key": "sk-your-api-key",
|
||||||
|
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
|
|
@ -793,6 +801,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `gemini` | LLM(Gemini 直接) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM(Gemini 直接) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM(Zhipu 直接) | [bigmodel.cn](https://bigmodel.cn) |
|
| `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) |
|
| `openrouter`(要テスト) | LLM(推奨、全モデルにアクセス可能) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `anthropic`(要テスト) | LLM(Claude 直接) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic`(要テスト) | LLM(Claude 直接) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai`(要テスト) | LLM(GPT 直接) | [platform.openai.com](https://platform.openai.com) |
|
| `openai`(要テスト) | LLM(GPT 直接) | [platform.openai.com](https://platform.openai.com) |
|
||||||
|
|
@ -921,10 +930,12 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
|
| **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 | - |
|
| **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) |
|
| **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のみ |
|
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
|
@ -1145,6 +1156,7 @@ Web 検索を有効にするには:
|
||||||
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
|
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
|
||||||
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
||||||
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
||||||
|
| **ModelScope** | 1 日 2000 リクエスト | 無料推論(Qwen, GLM, DeepSeek など) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
65
README.md
65
README.md
|
|
@ -4,15 +4,16 @@
|
||||||
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
|
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
|
||||||
|
|
||||||
<h3>$10 Hardware · 10MB RAM · 1s Boot · 皮皮虾,我们走!</h3>
|
<h3>$10 Hardware · 10MB RAM · 1s Boot · 皮皮虾,我们走!</h3>
|
||||||
|
|
||||||
<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/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/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">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<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://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>
|
<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="./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>
|
<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>
|
</p>
|
||||||
|
|
@ -56,7 +57,7 @@
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](ROADMAP.md) —we can’t wait to have you on board!
|
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](ROADMAP.md) —we can’t wait to have you on board!
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
||||||
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
|
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
|
||||||
|
|
||||||
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
|
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
|
||||||
|
|
@ -227,7 +228,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
### 🚀 Quick Start
|
### 🚀 Quick Start
|
||||||
|
|
||||||
> [!TIP]
|
> [!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**
|
**1. Initialize**
|
||||||
|
|
||||||
|
|
@ -252,7 +253,8 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key"
|
"api_key": "sk-your-api-key",
|
||||||
|
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
|
|
@ -991,18 +993,20 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level.
|
> 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 |
|
| Provider | Purpose | Get API Key |
|
||||||
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
|
| ------------ | --------------------------------------- | ------------------------------------------------------------ |
|
||||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
|
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
|
||||||
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
| `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) |
|
||||||
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||||
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||||
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||||
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
|
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||||
| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.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)
|
### 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 |
|
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
| **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 | - |
|
| **神算云** | `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) |
|
| **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) |
|
| **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 |
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **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.
|
> 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)**
|
**Ollama (local)**
|
||||||
|
|
||||||
```json
|
```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) |
|
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
||||||
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
||||||
| **LongCat** | Up to 5M tokens/day | Fast inference (free tier) |
|
| **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>
|
<h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>
|
||||||
|
|
||||||
<h3>Hardware de $10 · 10MB de RAM · Boot em 1s · 皮皮虾,我们走!</h3>
|
<h3>Hardware de $10 · 10MB de RAM · Boot em 1s · 皮皮虾,我们走!</h3>
|
||||||
|
|
||||||
<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/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/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">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<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://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="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>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
|
[中文](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
|
### 🚀 Início Rápido
|
||||||
|
|
||||||
> [!TIP]
|
> [!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**
|
**1. Inicializar**
|
||||||
|
|
||||||
|
|
@ -223,7 +227,8 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key"
|
"api_key": "sk-your-api-key",
|
||||||
|
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"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) |
|
| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](bigmodel.cn) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) |
|
| **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 | - |
|
| **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) |
|
| **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 |
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **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 |
|
| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web |
|
||||||
| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) |
|
| **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) |
|
| **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.) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
21
README.vi.md
21
README.vi.md
|
|
@ -4,14 +4,18 @@
|
||||||
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
|
<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>
|
<h3>Phần cứng $10 · RAM 10MB · Khởi động 1 giây · Nào, xuất phát!</h3>
|
||||||
|
|
||||||
<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/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/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">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<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://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="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>
|
</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)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [English](README.md)
|
||||||
|
|
@ -52,7 +56,7 @@
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/ROADMAP.md) — rất mong đón nhận sự tham gia của bạn!
|
2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/ROADMAP.md) — rất mong đón nhận sự tham gia của bạn!
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
|
2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
|
||||||
🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
|
🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
|
||||||
|
|
||||||
2026-02-09 🎉 PicoClaw chính thức ra mắt! Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường!
|
2026-02-09 🎉 PicoClaw chính thức ra mắt! Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường!
|
||||||
|
|
@ -187,7 +191,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
### 🚀 Bắt đầu nhanh
|
### 🚀 Bắt đầu nhanh
|
||||||
|
|
||||||
> [!TIP]
|
> [!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**
|
**1. Khởi tạo**
|
||||||
|
|
||||||
|
|
@ -203,7 +207,8 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key"
|
"api_key": "sk-your-api-key",
|
||||||
|
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) |
|
| **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 | - |
|
| **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) |
|
| **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 |
|
| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **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 |
|
| **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 |
|
| **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) |
|
| **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.) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
49
README.zh.md
49
README.zh.md
|
|
@ -4,14 +4,18 @@
|
||||||
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
||||||
|
|
||||||
<h3>10$硬件 · 10MB内存 · 1秒启动 · 皮皮虾,我们走!</h3>
|
<h3>10$硬件 · 10MB内存 · 1秒启动 · 皮皮虾,我们走!</h3>
|
||||||
|
|
||||||
<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/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/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">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<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://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="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>
|
</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)
|
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
|
||||||
|
|
@ -117,7 +121,7 @@ pkg install proot
|
||||||
termux-chroot ./picoclaw-linux-arm64 onboard
|
termux-chroot ./picoclaw-linux-arm64 onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
|
然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
|
||||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
### 🐜 创新的低占用部署
|
### 🐜 创新的低占用部署
|
||||||
|
|
@ -208,7 +212,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
### 🚀 快速开始
|
### 🚀 快速开始
|
||||||
|
|
||||||
> [!TIP]
|
> [!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)**
|
**1. 初始化 (Initialize)**
|
||||||
|
|
||||||
|
|
@ -234,7 +238,8 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-your-api-key"
|
"api_key": "sk-your-api-key",
|
||||||
|
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
|
|
@ -481,10 +486,11 @@ Agent 读取 HEARTBEAT.md
|
||||||
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
||||||
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
|
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
|
||||||
| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
| `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) |
|
||||||
| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
|
| `openrouter` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
|
| `anthropic` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.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) |
|
| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||||
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
|
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||||
| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
|
| `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) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
|
| **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 | - |
|
| **神算云** | `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) |
|
| **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 |
|
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
|
@ -612,6 +620,26 @@ Agent 读取 HEARTBEAT.md
|
||||||
|
|
||||||
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
|
> 运行 `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 (本地)**
|
**Ollama (本地)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -900,6 +928,7 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
||||||
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
|
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
|
||||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
||||||
| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) |
|
| **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"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/chzyer/readline"
|
"github.com/ergochat/readline"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/agent"
|
"github.com/sipeed/picoclaw/pkg/agent"
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ package gateway
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
|
@ -41,12 +41,31 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/voice"
|
"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 {
|
func gatewayCmd(debug bool) error {
|
||||||
if debug {
|
if debug {
|
||||||
logger.SetLevel(logger.DEBUG)
|
logger.SetLevel(logger.DEBUG)
|
||||||
fmt.Println("🔍 Debug mode enabled")
|
fmt.Println("🔍 Debug mode enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configPath := internal.GetConfigPath()
|
||||||
cfg, err := internal.LoadConfig()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error loading config: %w", err)
|
return fmt.Errorf("error loading config: %w", err)
|
||||||
|
|
@ -83,9 +102,55 @@ func gatewayCmd(debug bool) error {
|
||||||
"skills_available": skillsInfo["available"],
|
"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
|
// Setup cron tool and service
|
||||||
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
||||||
cronService := setupCronTool(
|
services.CronService = setupCronTool(
|
||||||
agentLoop,
|
agentLoop,
|
||||||
msgBus,
|
msgBus,
|
||||||
cfg.WorkspacePath(),
|
cfg.WorkspacePath(),
|
||||||
|
|
@ -93,20 +158,26 @@ func gatewayCmd(debug bool) error {
|
||||||
execTimeout,
|
execTimeout,
|
||||||
cfg,
|
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.WorkspacePath(),
|
||||||
cfg.Heartbeat.Interval,
|
cfg.Heartbeat.Interval,
|
||||||
cfg.Heartbeat.Enabled,
|
cfg.Heartbeat.Enabled,
|
||||||
)
|
)
|
||||||
heartbeatService.SetBus(msgBus)
|
services.HeartbeatService.SetBus(msgBus)
|
||||||
heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
services.HeartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||||
// Use cli:direct as fallback if no valid channel
|
// Use cli:direct as fallback if no valid channel
|
||||||
if channel == "" || chatID == "" {
|
if channel == "" || chatID == "" {
|
||||||
channel, chatID = "cli", "direct"
|
channel, chatID = "cli", "direct"
|
||||||
}
|
}
|
||||||
// Use ProcessHeartbeat - no session history, each heartbeat is independent
|
// Use ProcessHeartbeat - no session history, each heartbeat is independent
|
||||||
var response string
|
var response string
|
||||||
|
var err error
|
||||||
response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
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
|
// sent to user via processSystemMessage when the async task completes
|
||||||
return tools.SilentResult(response)
|
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
|
// 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,
|
Enabled: cfg.Tools.MediaCleanup.Enabled,
|
||||||
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
|
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
|
||||||
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * 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 {
|
if err != nil {
|
||||||
mediaStore.Stop()
|
// Stop the media store if it's a FileMediaStore with cleanup
|
||||||
return fmt.Errorf("error creating channel manager: %w", err)
|
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
|
// Inject channel manager and media store into agent loop
|
||||||
agentLoop.SetChannelManager(channelManager)
|
agentLoop.SetChannelManager(services.ChannelManager)
|
||||||
agentLoop.SetMediaStore(mediaStore)
|
agentLoop.SetMediaStore(services.MediaStore)
|
||||||
|
|
||||||
// Wire up voice transcription if a supported provider is configured.
|
// Wire up voice transcription if a supported provider is configured.
|
||||||
if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
|
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()})
|
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
|
||||||
}
|
}
|
||||||
|
|
||||||
enabledChannels := channelManager.GetEnabledChannels()
|
enabledChannels := services.ChannelManager.GetEnabledChannels()
|
||||||
if len(enabledChannels) > 0 {
|
if len(enabledChannels) > 0 {
|
||||||
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("⚠ Warning: No channels enabled")
|
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
|
// 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)
|
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 {
|
if err := services.ChannelManager.StartAll(context.Background()); err != nil {
|
||||||
fmt.Printf("Error starting channels: %v\n", err)
|
return nil, fmt.Errorf("error starting channels: %w", err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
|
||||||
go agentLoop.Run(ctx)
|
// Setup state manager and device service
|
||||||
|
stateManager := state.NewManager(cfg.WorkspacePath())
|
||||||
sigChan := make(chan os.Signal, 1)
|
services.DeviceService = devices.NewService(devices.Config{
|
||||||
signal.Notify(sigChan, os.Interrupt)
|
Enabled: cfg.Devices.Enabled,
|
||||||
<-sigChan
|
MonitorUSB: cfg.Devices.MonitorUSB,
|
||||||
|
}, stateManager)
|
||||||
fmt.Println("\nShutting down...")
|
services.DeviceService.SetBus(msgBus)
|
||||||
if cp, ok := provider.(providers.StatefulProvider); ok {
|
if err := services.DeviceService.Start(context.Background()); err != nil {
|
||||||
cp.Close()
|
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,
|
return services, nil
|
||||||
// since the original ctx is already canceled.
|
}
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
|
// stopAndCleanupServices stops all services and cleans up resources
|
||||||
|
func stopAndCleanupServices(
|
||||||
|
services *gatewayServices,
|
||||||
|
shutdownTimeout time.Duration,
|
||||||
|
) {
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
defer shutdownCancel()
|
defer shutdownCancel()
|
||||||
|
|
||||||
channelManager.StopAll(shutdownCtx)
|
if services.ChannelManager != nil {
|
||||||
deviceService.Stop()
|
services.ChannelManager.StopAll(shutdownCtx)
|
||||||
heartbeatService.Stop()
|
}
|
||||||
cronService.Stop()
|
if services.DeviceService != nil {
|
||||||
mediaStore.Stop()
|
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.Stop()
|
||||||
agentLoop.Close()
|
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
|
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(
|
func setupCronTool(
|
||||||
agentLoop *agent.AgentLoop,
|
agentLoop *agent.AgentLoop,
|
||||||
msgBus *bus.MessageBus,
|
msgBus *bus.MessageBus,
|
||||||
|
|
@ -239,7 +625,7 @@ func setupCronTool(
|
||||||
var err error
|
var err error
|
||||||
cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Critical error during CronTool initialization: %v", err)
|
logger.Fatalf("Critical error during CronTool initialization: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
agentLoop.RegisterTool(cronTool)
|
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.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
|
// get global config directory and builtin skills directory
|
||||||
globalDir := filepath.Dir(internal.GetConfigPath())
|
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/cron"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
|
"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/onboard"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
||||||
|
|
@ -43,6 +44,7 @@ func NewPicoclawCommand() *cobra.Command {
|
||||||
cron.NewCronCommand(),
|
cron.NewCronCommand(),
|
||||||
migrate.NewMigrateCommand(),
|
migrate.NewMigrateCommand(),
|
||||||
skills.NewSkillsCommand(),
|
skills.NewSkillsCommand(),
|
||||||
|
model.NewModelCommand(),
|
||||||
version.NewVersionCommand(),
|
version.NewVersionCommand(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||||
"cron",
|
"cron",
|
||||||
"gateway",
|
"gateway",
|
||||||
"migrate",
|
"migrate",
|
||||||
|
"model",
|
||||||
"onboard",
|
"onboard",
|
||||||
"skills",
|
"skills",
|
||||||
"status",
|
"status",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,13 @@
|
||||||
"api_base": "https://api.anthropic.com/v1",
|
"api_base": "https://api.anthropic.com/v1",
|
||||||
"thinking_level": "high"
|
"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_name": "gemini",
|
||||||
"model": "antigravity/gemini-2.0-flash",
|
"model": "antigravity/gemini-2.0-flash",
|
||||||
|
|
@ -40,6 +47,18 @@
|
||||||
"model": "longcat/LongCat-Flash-Thinking",
|
"model": "longcat/LongCat-Flash-Thinking",
|
||||||
"api_key": "your-longcat-api-key"
|
"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_name": "loadbalanced-gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
|
|
@ -283,6 +302,10 @@
|
||||||
"longcat": {
|
"longcat": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"api_base": "https://api.longcat.chat/openai"
|
"api_base": "https://api.longcat.chat/openai"
|
||||||
|
},
|
||||||
|
"modelscope": {
|
||||||
|
"api_key": "",
|
||||||
|
"api_base": "https://api-inference.modelscope.cn/v1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
@ -427,6 +450,10 @@
|
||||||
"max_response_size": 0
|
"max_response_size": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"github": {
|
||||||
|
"proxy": "http://127.0.0.1:7891",
|
||||||
|
"token": ""
|
||||||
|
},
|
||||||
"max_concurrent_searches": 2,
|
"max_concurrent_searches": 2,
|
||||||
"search_cache": {
|
"search_cache": {
|
||||||
"max_size": 50,
|
"max_size": 50,
|
||||||
|
|
|
||||||
18
go.mod
18
go.mod
|
|
@ -4,20 +4,20 @@ go 1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/adhocore/gronx v1.19.6
|
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/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.4.0
|
||||||
github.com/chzyer/readline v1.5.1
|
|
||||||
github.com/ergochat/irc-go v0.5.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/gdamore/tcell/v2 v2.13.8
|
||||||
github.com/google/uuid v1.6.0
|
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
|
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/gorilla/websocket v1.5.3
|
||||||
github.com/h2non/filetype v1.1.3
|
github.com/h2non/filetype v1.1.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.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/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||||
github.com/openai/openai-go/v3 v3.22.0
|
github.com/openai/openai-go/v3 v3.22.0
|
||||||
github.com/rivo/tview v0.42.0
|
github.com/rivo/tview v0.42.0
|
||||||
|
|
@ -27,9 +27,10 @@ require (
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tencent-connect/botgo v0.2.1
|
github.com/tencent-connect/botgo v0.2.1
|
||||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
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
|
golang.org/x/time v0.14.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
maunium.net/go/mautrix v0.26.3
|
maunium.net/go/mautrix v0.26.3
|
||||||
modernc.org/sqlite v1.46.1
|
modernc.org/sqlite v1.46.1
|
||||||
)
|
)
|
||||||
|
|
@ -60,7 +61,6 @@ require (
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||||
golang.org/x/term v0.40.0 // indirect
|
golang.org/x/term v0.40.0 // indirect
|
||||||
golang.org/x/text v0.34.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/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|
@ -73,7 +73,7 @@ require (
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/github/copilot-sdk/go v0.1.23
|
github.com/github/copilot-sdk/go v0.1.32
|
||||||
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
|
|
@ -87,7 +87,7 @@ require (
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.69.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
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
golang.org/x/arch v0.24.0 // indirect
|
golang.org/x/arch v0.24.0 // indirect
|
||||||
golang.org/x/crypto v0.48.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/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 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
|
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
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 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
||||||
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
||||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
github.com/bwmarrin/discordgo v0.29.0 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 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 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
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.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
|
||||||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
|
|
@ -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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
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 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw=
|
||||||
github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
|
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.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
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 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||||
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||||
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
github.com/github/copilot-sdk/go v0.1.32 h1:wc9SFWwxXhJts6vyzzboPLJqcEJGnHE8rMCAY1RrUgo=
|
||||||
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
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-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||||
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||||
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
||||||
|
|
@ -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/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 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw=
|
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.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=
|
||||||
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
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/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 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
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.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
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 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
|
@ -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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
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 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
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.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
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-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-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/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-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
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-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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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.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.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.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.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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,9 @@ type AgentLoop struct {
|
||||||
transcriber voice.Transcriber
|
transcriber voice.Transcriber
|
||||||
cmdRegistry *commands.Registry
|
cmdRegistry *commands.Registry
|
||||||
mcp mcpRuntime
|
mcp mcpRuntime
|
||||||
|
mu sync.RWMutex
|
||||||
|
// Track active requests for safe provider cleanup
|
||||||
|
activeRequests sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// processOptions configures how a message is processed
|
// processOptions configures how a message is processed
|
||||||
|
|
@ -239,6 +242,7 @@ func registerSharedTools(
|
||||||
|
|
||||||
func (al *AgentLoop) Run(ctx context.Context) error {
|
func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
al.running.Store(true)
|
al.running.Store(true)
|
||||||
|
|
||||||
if err := al.ensureMCPInitialized(ctx); err != nil {
|
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||||
return err
|
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.
|
// If so, skip publishing to avoid duplicate messages to the user.
|
||||||
// Use default agent's tools to check (message tool is shared).
|
// Use default agent's tools to check (message tool is shared).
|
||||||
alreadySent := false
|
alreadySent := false
|
||||||
defaultAgent := al.registry.GetDefaultAgent()
|
defaultAgent := al.GetRegistry().GetDefaultAgent()
|
||||||
if defaultAgent != nil {
|
if defaultAgent != nil {
|
||||||
if tool, ok := defaultAgent.Tools.Get("message"); ok {
|
if tool, ok := defaultAgent.Tools.Get("message"); ok {
|
||||||
if mt, ok := tool.(*tools.MessageTool); 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) {
|
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
|
||||||
for _, agentID := range al.registry.ListAgentIDs() {
|
registry := al.GetRegistry()
|
||||||
if agent, ok := al.registry.GetAgent(agentID); ok {
|
for _, agentID := range registry.ListAgentIDs() {
|
||||||
|
if agent, ok := registry.GetAgent(agentID); ok {
|
||||||
agent.Tools.Register(tool)
|
agent.Tools.Register(tool)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -346,12 +351,123 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
||||||
al.channelManager = cm
|
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.
|
// SetMediaStore injects a MediaStore for media lifecycle management.
|
||||||
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
||||||
al.mediaStore = s
|
al.mediaStore = s
|
||||||
|
|
||||||
// Propagate store to send_file tools in all agents.
|
// 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 {
|
if sf, ok := t.(*tools.SendFileTool); ok {
|
||||||
sf.SetMediaStore(s)
|
sf.SetMediaStore(s)
|
||||||
}
|
}
|
||||||
|
|
@ -540,7 +656,7 @@ func (al *AgentLoop) ProcessHeartbeat(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
content, channel, chatID string,
|
content, channel, chatID string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
agent := al.registry.GetDefaultAgent()
|
agent := al.GetRegistry().GetDefaultAgent()
|
||||||
if agent == nil {
|
if agent == nil {
|
||||||
return "", fmt.Errorf("no default agent for heartbeat")
|
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) {
|
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,
|
Channel: msg.Channel,
|
||||||
AccountID: inboundMetadata(msg, metadataKeyAccountID),
|
AccountID: inboundMetadata(msg, metadataKeyAccountID),
|
||||||
Peer: extractPeer(msg),
|
Peer: extractPeer(msg),
|
||||||
|
|
@ -645,9 +762,9 @@ func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.Resolv
|
||||||
TeamID: inboundMetadata(msg, metadataKeyTeamID),
|
TeamID: inboundMetadata(msg, metadataKeyTeamID),
|
||||||
})
|
})
|
||||||
|
|
||||||
agent, ok := al.registry.GetAgent(route.AgentID)
|
agent, ok := registry.GetAgent(route.AgentID)
|
||||||
if !ok {
|
if !ok {
|
||||||
agent = al.registry.GetDefaultAgent()
|
agent = registry.GetDefaultAgent()
|
||||||
}
|
}
|
||||||
if agent == nil {
|
if agent == nil {
|
||||||
return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
|
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
|
// Use default agent for system messages
|
||||||
agent := al.registry.GetDefaultAgent()
|
agent := al.GetRegistry().GetDefaultAgent()
|
||||||
if agent == nil {
|
if agent == nil {
|
||||||
return "", fmt.Errorf("no default agent for system message")
|
return "", fmt.Errorf("no default agent for system message")
|
||||||
}
|
}
|
||||||
|
|
@ -764,8 +881,9 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
opts.ChatID,
|
opts.ChatID,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Resolve media:// refs to base64 data URLs (streaming)
|
// Resolve media:// refs: images→base64 data URLs, non-images→local paths in content
|
||||||
maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize()
|
cfg := al.GetConfig()
|
||||||
|
maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize()
|
||||||
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
||||||
|
|
||||||
// 2. Save user message to session
|
// 2. Save user message to session
|
||||||
|
|
@ -943,6 +1061,9 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
}
|
}
|
||||||
|
|
||||||
callLLM := func() (*providers.LLMResponse, error) {
|
callLLM := func() (*providers.LLMResponse, error) {
|
||||||
|
al.activeRequests.Add(1)
|
||||||
|
defer al.activeRequests.Done()
|
||||||
|
|
||||||
if len(activeCandidates) > 1 && al.fallback != nil {
|
if len(activeCandidates) > 1 && al.fallback != nil {
|
||||||
fbResult, fbErr := al.fallback.Execute(
|
fbResult, fbErr := al.fallback.Execute(
|
||||||
ctx,
|
ctx,
|
||||||
|
|
@ -1041,6 +1162,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
|
"model": activeModel,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
|
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 {
|
func (al *AgentLoop) GetStartupInfo() map[string]any {
|
||||||
info := make(map[string]any)
|
info := make(map[string]any)
|
||||||
|
|
||||||
agent := al.registry.GetDefaultAgent()
|
registry := al.GetRegistry()
|
||||||
|
agent := registry.GetDefaultAgent()
|
||||||
if agent == nil {
|
if agent == nil {
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
@ -1409,8 +1532,8 @@ func (al *AgentLoop) GetStartupInfo() map[string]any {
|
||||||
|
|
||||||
// Agents info
|
// Agents info
|
||||||
info["agents"] = map[string]any{
|
info["agents"] = map[string]any{
|
||||||
"count": len(al.registry.ListAgentIDs()),
|
"count": len(registry.ListAgentIDs()),
|
||||||
"ids": al.registry.ListAgentIDs(),
|
"ids": registry.ListAgentIDs(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return info
|
return info
|
||||||
|
|
@ -1598,17 +1721,22 @@ func (al *AgentLoop) retryLLMCall(
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
resp, err = agent.Provider.Chat(
|
al.activeRequests.Add(1)
|
||||||
ctx,
|
resp, err = func() (*providers.LLMResponse, error) {
|
||||||
[]providers.Message{{Role: "user", Content: prompt}},
|
defer al.activeRequests.Done()
|
||||||
nil,
|
return agent.Provider.Chat(
|
||||||
agent.Model,
|
ctx,
|
||||||
map[string]any{
|
[]providers.Message{{Role: "user", Content: prompt}},
|
||||||
"max_tokens": agent.MaxTokens,
|
nil,
|
||||||
"temperature": llmTemperature,
|
agent.Model,
|
||||||
"prompt_cache_key": agent.ID,
|
map[string]any{
|
||||||
},
|
"max_tokens": agent.MaxTokens,
|
||||||
)
|
"temperature": llmTemperature,
|
||||||
|
"prompt_cache_key": agent.ID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
if err == nil && resp != nil && resp.Content != "" {
|
if err == nil && resp != nil && resp.Content != "" {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
@ -1741,9 +1869,11 @@ func (al *AgentLoop) handleCommand(
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime {
|
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime {
|
||||||
|
registry := al.GetRegistry()
|
||||||
|
cfg := al.GetConfig()
|
||||||
rt := &commands.Runtime{
|
rt := &commands.Runtime{
|
||||||
Config: al.cfg,
|
Config: cfg,
|
||||||
ListAgentIDs: al.registry.ListAgentIDs,
|
ListAgentIDs: registry.ListAgentIDs,
|
||||||
ListDefinitions: al.cmdRegistry.Definitions,
|
ListDefinitions: al.cmdRegistry.Definitions,
|
||||||
GetEnabledChannels: func() []string {
|
GetEnabledChannels: func() []string {
|
||||||
if al.channelManager == nil {
|
if al.channelManager == nil {
|
||||||
|
|
@ -1763,7 +1893,7 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
|
||||||
}
|
}
|
||||||
if agent != nil {
|
if agent != nil {
|
||||||
rt.GetModelInfo = func() (string, string) {
|
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) {
|
rt.SwitchModel = func(value string) (string, error) {
|
||||||
oldModel := agent.Model
|
oldModel := agent.Model
|
||||||
|
|
@ -1827,3 +1957,16 @@ func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
}
|
}
|
||||||
return &routing.RoutePeer{Kind: parentKind, ID: parentID}
|
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
|
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() {
|
al.mcp.initOnce.Do(func() {
|
||||||
mcpManager := mcp.NewManager()
|
mcpManager := mcp.NewManager()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,10 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
// resolveMediaRefs replaces media:// refs in message Media fields with base64 data URLs.
|
// resolveMediaRefs resolves media:// refs in messages.
|
||||||
// Uses streaming base64 encoding (file handle → encoder → buffer) to avoid holding
|
// Images are base64-encoded into the Media array for multimodal LLMs.
|
||||||
// both raw bytes and encoded string in memory simultaneously.
|
// 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.
|
// Returns a new slice; original messages are not mutated.
|
||||||
func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message {
|
func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message {
|
||||||
if store == nil {
|
if store == nil {
|
||||||
|
|
@ -38,6 +39,8 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
|
||||||
}
|
}
|
||||||
|
|
||||||
resolved := make([]string, 0, len(m.Media))
|
resolved := make([]string, 0, len(m.Media))
|
||||||
|
var pathTags []string
|
||||||
|
|
||||||
for _, ref := range m.Media {
|
for _, ref := range m.Media {
|
||||||
if !strings.HasPrefix(ref, "media://") {
|
if !strings.HasPrefix(ref, "media://") {
|
||||||
resolved = append(resolved, ref)
|
resolved = append(resolved, ref)
|
||||||
|
|
@ -61,62 +64,117 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine MIME type: prefer metadata, fallback to magic-bytes detection
|
mime := detectMIME(localPath, meta)
|
||||||
mime := meta.ContentType
|
|
||||||
if mime == "" {
|
if strings.HasPrefix(mime, "image/") {
|
||||||
kind, ftErr := filetype.MatchFile(localPath)
|
dataURL := encodeImageToDataURL(localPath, mime, info, maxSize)
|
||||||
if ftErr != nil || kind == filetype.Unknown {
|
if dataURL != "" {
|
||||||
logger.WarnCF("agent", "Unknown media type, skipping", map[string]any{
|
resolved = append(resolved, dataURL)
|
||||||
"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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
prefix := "data:" + mime + ";base64,"
|
pathTags = append(pathTags, buildPathTag(mime, localPath))
|
||||||
encodedLen := base64.StdEncoding.EncodedLen(int(info.Size()))
|
|
||||||
var buf bytes.Buffer
|
|
||||||
buf.Grow(len(prefix) + encodedLen)
|
|
||||||
buf.WriteString(prefix)
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
encoder.Close()
|
|
||||||
f.Close()
|
|
||||||
|
|
||||||
resolved = append(resolved, buf.String())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result[i].Media = resolved
|
result[i].Media = resolved
|
||||||
|
if len(pathTags) > 0 {
|
||||||
|
result[i].Content = injectPathTags(result[i].Content, pathTags)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
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,
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to open media file", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
prefix := "data:" + mime + ";base64,"
|
||||||
|
encodedLen := base64.StdEncoding.EncodedLen(int(info.Size()))
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.Grow(len(prefix) + encodedLen)
|
||||||
|
buf.WriteString(prefix)
|
||||||
|
|
||||||
|
encoder := base64.NewEncoder(base64.StdEncoding, &buf)
|
||||||
|
if _, err := io.Copy(encoder, f); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to encode media file", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
encoder.Close()
|
||||||
|
|
||||||
|
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-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
// Test with MCP enabled but no servers - should not initialize manager
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
|
|
@ -791,6 +796,7 @@ func TestProcessDirectWithChannel_InitializesMCPInAgentMode(t *testing.T) {
|
||||||
ToolConfig: config.ToolConfig{
|
ToolConfig: config.ToolConfig{
|
||||||
Enabled: true,
|
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)
|
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !al.mcp.hasManager() {
|
// Manager should not be initialized when no servers are configured
|
||||||
t.Fatal("expected MCP manager to be initialized in direct agent mode")
|
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()
|
store := media.NewFileMediaStore()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
|
@ -1104,7 +1111,11 @@ func TestResolveMediaRefs_SkipsUnknownType(t *testing.T) {
|
||||||
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||||
|
|
||||||
if len(result[0].Media) != 0 {
|
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])
|
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
|
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 {
|
if err != nil {
|
||||||
logger.ErrorCF("pico", "WebSocket upgrade failed", map[string]any{
|
logger.ErrorCF("pico", "WebSocket upgrade failed", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -282,8 +288,10 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
go c.readLoop(pc)
|
go c.readLoop(pc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// authenticate checks the Bearer token from the Authorization header.
|
// authenticate checks the request for a valid token:
|
||||||
// Query parameter authentication is only allowed when AllowTokenQuery is explicitly enabled.
|
// 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 {
|
func (c *PicoChannel) authenticate(r *http.Request) bool {
|
||||||
token := c.config.Token
|
token := c.config.Token
|
||||||
if 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
|
// Check query parameter only when explicitly allowed
|
||||||
if c.config.AllowTokenQuery {
|
if c.config.AllowTokenQuery {
|
||||||
if r.URL.Query().Get("token") == token {
|
if r.URL.Query().Get("token") == token {
|
||||||
|
|
@ -308,6 +321,18 @@ func (c *PicoChannel) authenticate(r *http.Request) bool {
|
||||||
return false
|
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.
|
// readLoop reads messages from a WebSocket connection.
|
||||||
func (c *PicoChannel) readLoop(pc *picoConn) {
|
func (c *PicoChannel) readLoop(pc *picoConn) {
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
|
||||||
|
|
@ -423,7 +423,9 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
// Reset msg_seq counter for new inbound message.
|
// Reset msg_seq counter for new inbound message.
|
||||||
c.msgSeqCounters.Store(senderID, new(atomic.Uint64))
|
c.msgSeqCounters.Store(senderID, new(atomic.Uint64))
|
||||||
|
|
||||||
metadata := map[string]string{}
|
metadata := map[string]string{
|
||||||
|
"account_id": senderID,
|
||||||
|
}
|
||||||
|
|
||||||
sender := bus.SenderInfo{
|
sender := bus.SenderInfo{
|
||||||
Platform: "qq",
|
Platform: "qq",
|
||||||
|
|
@ -495,7 +497,8 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64))
|
c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64))
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"group_id": data.GroupID,
|
"account_id": senderID,
|
||||||
|
"group_id": data.GroupID,
|
||||||
}
|
}
|
||||||
|
|
||||||
sender := bus.SenderInfo{
|
sender := bus.SenderInfo{
|
||||||
|
|
|
||||||
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"`
|
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"`
|
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
|
||||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||||
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
||||||
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
||||||
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
||||||
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||||
|
|
@ -555,6 +555,7 @@ type ProvidersConfig struct {
|
||||||
Avian ProviderConfig `json:"avian"`
|
Avian ProviderConfig `json:"avian"`
|
||||||
Minimax ProviderConfig `json:"minimax"`
|
Minimax ProviderConfig `json:"minimax"`
|
||||||
LongCat ProviderConfig `json:"longcat"`
|
LongCat ProviderConfig `json:"longcat"`
|
||||||
|
ModelScope ProviderConfig `json:"modelscope"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
// 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.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
|
||||||
p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
|
p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
|
||||||
p.Minimax.APIKey == "" && p.Minimax.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
|
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||||
|
|
@ -738,6 +740,7 @@ type ExecConfig struct {
|
||||||
type SkillsToolsConfig struct {
|
type SkillsToolsConfig struct {
|
||||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
|
||||||
Registries SkillsRegistriesConfig ` json:"registries"`
|
Registries SkillsRegistriesConfig ` json:"registries"`
|
||||||
|
Github SkillsGithubConfig ` json:"github"`
|
||||||
MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"`
|
MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"`
|
||||||
SearchCache SearchCacheConfig ` json:"search_cache"`
|
SearchCache SearchCacheConfig ` json:"search_cache"`
|
||||||
}
|
}
|
||||||
|
|
@ -787,6 +790,11 @@ type SkillsRegistriesConfig struct {
|
||||||
ClawHub ClawHubRegistryConfig `json:"clawhub"`
|
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 {
|
type ClawHubRegistryConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
|
||||||
BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
|
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)
|
t.Fatalf("ReadFile failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(string(data), `"model": ""`) {
|
if !strings.Contains(string(data), `"model_name": ""`) {
|
||||||
t.Fatalf("saved config should include empty legacy model field, got: %s", string(data))
|
t.Fatalf("saved config should include empty legacy model_name field, got: %s", string(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -369,6 +369,14 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "",
|
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
|
// VLLM (local) - http://localhost:8000
|
||||||
{
|
{
|
||||||
ModelName: "local-model",
|
ModelName: "local-model",
|
||||||
|
|
@ -376,6 +384,15 @@ func DefaultConfig() *Config {
|
||||||
APIBase: "http://localhost:8000/v1",
|
APIBase: "http://localhost:8000/v1",
|
||||||
APIKey: "",
|
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{
|
Gateway: GatewayConfig{
|
||||||
Host: "127.0.0.1",
|
Host: "127.0.0.1",
|
||||||
|
|
|
||||||
|
|
@ -424,6 +424,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
}, true
|
}, 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
|
// Process each provider migration
|
||||||
|
|
|
||||||
|
|
@ -163,14 +163,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
||||||
Mistral: ProviderConfig{APIKey: "key18"},
|
Mistral: ProviderConfig{APIKey: "key18"},
|
||||||
Avian: ProviderConfig{APIKey: "key19"},
|
Avian: ProviderConfig{APIKey: "key19"},
|
||||||
LongCat: ProviderConfig{APIKey: "key-longcat"},
|
LongCat: ProviderConfig{APIKey: "key-longcat"},
|
||||||
|
ModelScope: ProviderConfig{APIKey: "key-modelscope"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := ConvertProvidersToModelList(cfg)
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
// All 22 providers should be converted
|
// All 23 providers should be converted
|
||||||
if len(result) != 22 {
|
if len(result) != 23 {
|
||||||
t.Errorf("len(result) = %d, want 22", len(result))
|
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
|
// Strip leading "@" for username matching
|
||||||
trimmed := strings.TrimPrefix(allowed, "@")
|
trimmed := strings.TrimPrefix(allowed, "@")
|
||||||
|
|
||||||
|
|
@ -75,11 +78,9 @@ func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match against Username
|
// Match against Username only when explicitly requested via "@username"
|
||||||
if sender.Username != "" {
|
if isAtUsername && sender.Username != "" && sender.Username == trimmed {
|
||||||
if sender.Username == trimmed || sender.Username == allowedUser {
|
return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match compound sender format against allowed parts
|
// Match compound sender format against allowed parts
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,16 @@ func TestMatchAllowed(t *testing.T) {
|
||||||
allowed: "@alice",
|
allowed: "@alice",
|
||||||
want: true,
|
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",
|
name: "@username does not match",
|
||||||
sender: telegramSender,
|
sender: telegramSender,
|
||||||
|
|
@ -123,6 +133,16 @@ func TestMatchAllowed(t *testing.T) {
|
||||||
allowed: "999|alice",
|
allowed: "999|alice",
|
||||||
want: true,
|
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",
|
name: "compound does not match",
|
||||||
sender: telegramSender,
|
sender: telegramSender,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
|
@ -45,6 +46,9 @@ func init() {
|
||||||
consoleWriter := zerolog.ConsoleWriter{
|
consoleWriter := zerolog.ConsoleWriter{
|
||||||
Out: os.Stdout,
|
Out: os.Stdout,
|
||||||
TimeFormat: "15:04:05", // TODO: make it configurable???
|
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()
|
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) {
|
func SetLevel(level LogLevel) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
@ -113,6 +148,7 @@ func getCallerInfo() (string, int, string) {
|
||||||
|
|
||||||
// bypass common loggers
|
// bypass common loggers
|
||||||
if strings.HasSuffix(file, "/logger.go") ||
|
if strings.HasSuffix(file, "/logger.go") ||
|
||||||
|
strings.HasSuffix(file, "/logger_3rd_party.go") ||
|
||||||
strings.HasSuffix(file, "/log.go") {
|
strings.HasSuffix(file, "/log.go") {
|
||||||
continue
|
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))
|
event.Str("caller", fmt.Sprintf("<none> %s:%d (%s)", callerFile, callerLine, callerFunc))
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range fields {
|
appendFields(event, fields)
|
||||||
event.Interface(k, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
event.Msg(message)
|
event.Msg(message)
|
||||||
|
|
||||||
// Also log to file if enabled
|
// Also log to file if enabled
|
||||||
|
|
@ -175,9 +208,8 @@ func logMessage(level LogLevel, component string, message string, fields map[str
|
||||||
if component != "" {
|
if component != "" {
|
||||||
fileEvent.Str("component", component)
|
fileEvent.Str("component", component)
|
||||||
}
|
}
|
||||||
for k, v := range fields {
|
|
||||||
fileEvent.Interface(k, v)
|
appendFields(event, fields)
|
||||||
}
|
|
||||||
fileEvent.Msg(message)
|
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) {
|
func Debug(message string) {
|
||||||
logMessage(DEBUG, "", message, nil)
|
logMessage(DEBUG, "", message, nil)
|
||||||
}
|
}
|
||||||
|
|
@ -194,6 +246,10 @@ func DebugC(component string, message string) {
|
||||||
logMessage(DEBUG, component, message, nil)
|
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) {
|
func DebugF(message string, fields map[string]any) {
|
||||||
logMessage(DEBUG, "", message, fields)
|
logMessage(DEBUG, "", message, fields)
|
||||||
}
|
}
|
||||||
|
|
@ -214,6 +270,10 @@ func InfoF(message string, fields map[string]any) {
|
||||||
logMessage(INFO, "", message, fields)
|
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) {
|
func InfoCF(component string, message string, fields map[string]any) {
|
||||||
logMessage(INFO, component, message, fields)
|
logMessage(INFO, component, message, fields)
|
||||||
}
|
}
|
||||||
|
|
@ -242,6 +302,10 @@ func ErrorC(component string, message string) {
|
||||||
logMessage(ERROR, component, message, nil)
|
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) {
|
func ErrorF(message string, fields map[string]any) {
|
||||||
logMessage(ERROR, "", message, fields)
|
logMessage(ERROR, "", message, fields)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,17 +123,132 @@ func TestLoggerHelperFunctions(t *testing.T) {
|
||||||
SetLevel(INFO)
|
SetLevel(INFO)
|
||||||
|
|
||||||
Debug("This should not log")
|
Debug("This should not log")
|
||||||
|
Debugf("this should not log")
|
||||||
Info("This should log")
|
Info("This should log")
|
||||||
Warn("This should log")
|
Warn("This should log")
|
||||||
Error("This should log")
|
Error("This should log")
|
||||||
|
|
||||||
InfoC("test", "Component message")
|
InfoC("test", "Component message")
|
||||||
InfoF("Fields message", map[string]any{"key": "value"})
|
InfoF("Fields message", map[string]any{"key": "value"})
|
||||||
|
Infof("test from %v", "Infof")
|
||||||
|
|
||||||
WarnC("test", "Warning with component")
|
WarnC("test", "Warning with component")
|
||||||
ErrorF("Error with fields", map[string]any{"error": "test"})
|
ErrorF("Error with fields", map[string]any{"error": "test"})
|
||||||
|
Errorf("test from %v", "Errorf")
|
||||||
|
|
||||||
SetLevel(DEBUG)
|
SetLevel(DEBUG)
|
||||||
DebugC("test", "Debug with component")
|
DebugC("test", "Debug with component")
|
||||||
|
Debugf("test from %v", "Debugf")
|
||||||
WarnF("Warning with fields", map[string]any{"key": "value"})
|
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"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"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.
|
// 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.
|
// CreateProviderFromConfig creates a provider based on the ModelConfig.
|
||||||
// It uses the protocol prefix in the Model field to determine which provider to create.
|
// It uses the protocol prefix in the Model field to determine which provider to create.
|
||||||
// Supported 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.
|
// Returns the provider, the model ID (without protocol prefix), and any error.
|
||||||
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
|
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
|
|
@ -92,10 +95,28 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
), modelID, nil
|
), 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",
|
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||||
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
|
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
|
||||||
"minimax", "longcat":
|
"minimax", "longcat", "modelscope":
|
||||||
// All other OpenAI-compatible HTTP providers
|
// All other OpenAI-compatible HTTP providers
|
||||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
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,
|
cfg.RequestTimeout,
|
||||||
), modelID, nil
|
), 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":
|
case "antigravity":
|
||||||
return NewAntigravityProvider(), modelID, nil
|
return NewAntigravityProvider(), modelID, nil
|
||||||
|
|
||||||
|
|
@ -217,6 +253,8 @@ func getDefaultAPIBase(protocol string) string {
|
||||||
return "https://api.minimaxi.com/v1"
|
return "https://api.minimaxi.com/v1"
|
||||||
case "longcat":
|
case "longcat":
|
||||||
return "https://api.longcat.chat/openai"
|
return "https://api.longcat.chat/openai"
|
||||||
|
case "modelscope":
|
||||||
|
return "https://api-inference.modelscope.cn/v1"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,12 @@ func TestExtractProtocol(t *testing.T) {
|
||||||
wantProtocol: "nvidia",
|
wantProtocol: "nvidia",
|
||||||
wantModelID: "meta/llama-3.1-8b",
|
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 {
|
for _, tt := range tests {
|
||||||
|
|
@ -114,6 +120,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
|
||||||
{"deepseek", "deepseek"},
|
{"deepseek", "deepseek"},
|
||||||
{"ollama", "ollama"},
|
{"ollama", "ollama"},
|
||||||
{"longcat", "longcat"},
|
{"longcat", "longcat"},
|
||||||
|
{"modelscope", "modelscope"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
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) {
|
func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-anthropic",
|
ModelName: "test-anthropic",
|
||||||
|
|
@ -341,3 +377,69 @@ func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) {
|
||||||
t.Fatalf("Chat() error = %q, want timeout-related error", errMsg)
|
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
|
package openai_compat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -38,7 +36,7 @@ type Provider struct {
|
||||||
|
|
||||||
type Option func(*Provider)
|
type Option func(*Provider)
|
||||||
|
|
||||||
const defaultRequestTimeout = 120 * time.Second
|
const defaultRequestTimeout = common.DefaultRequestTimeout
|
||||||
|
|
||||||
func WithMaxTokensField(maxTokensField string) Option {
|
func WithMaxTokensField(maxTokensField string) Option {
|
||||||
return func(p *Provider) {
|
return func(p *Provider) {
|
||||||
|
|
@ -55,25 +53,10 @@ func WithRequestTimeout(timeout time.Duration) Option {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
|
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{
|
p := &Provider{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: strings.TrimRight(apiBase, "/"),
|
apiBase: strings.TrimRight(apiBase, "/"),
|
||||||
httpClient: client,
|
httpClient: common.NewHTTPClient(proxy),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
|
|
@ -117,7 +100,7 @@ func (p *Provider) Chat(
|
||||||
|
|
||||||
requestBody := map[string]any{
|
requestBody := map[string]any{
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": serializeMessages(messages),
|
"messages": common.SerializeMessages(messages),
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tools) > 0 {
|
if len(tools) > 0 {
|
||||||
|
|
@ -125,7 +108,7 @@ func (p *Provider) Chat(
|
||||||
requestBody["tool_choice"] = "auto"
|
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
|
// Use configured maxTokensField if specified, otherwise fallback to model-based detection
|
||||||
fieldName := p.maxTokensField
|
fieldName := p.maxTokensField
|
||||||
if fieldName == "" {
|
if fieldName == "" {
|
||||||
|
|
@ -141,7 +124,7 @@ func (p *Provider) Chat(
|
||||||
requestBody[fieldName] = maxTokens
|
requestBody[fieldName] = maxTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
if temperature, ok := asFloat(options["temperature"]); ok {
|
if temperature, ok := common.AsFloat(options["temperature"]); ok {
|
||||||
lowerModel := strings.ToLower(model)
|
lowerModel := strings.ToLower(model)
|
||||||
// Kimi k2 models only support temperature=1.
|
// Kimi k2 models only support temperature=1.
|
||||||
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
|
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
|
||||||
|
|
@ -185,275 +168,11 @@ func (p *Provider) Chat(
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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 {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
|
return nil, common.HandleErrorResponse(resp, p.apiBase)
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peek without consuming so the full stream reaches the JSON decoder.
|
return common.ReadAndParseResponse(resp, p.apiBase)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeModel(model, apiBase string) string {
|
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
|
// supportsPromptCacheKey reports whether the given API base is known to
|
||||||
// support the prompt_cache_key request field. Currently only OpenAI's own
|
// support the prompt_cache_key request field. Currently only OpenAI's own
|
||||||
// API and Azure OpenAI support this. All other OpenAI-compatible providers
|
// API and Azure OpenAI support this. All other OpenAI-compatible providers
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -648,7 +649,7 @@ func TestSerializeMessages_PlainText(t *testing.T) {
|
||||||
{Role: "user", Content: "hello"},
|
{Role: "user", Content: "hello"},
|
||||||
{Role: "assistant", Content: "hi", ReasoningContent: "thinking..."},
|
{Role: "assistant", Content: "hi", ReasoningContent: "thinking..."},
|
||||||
}
|
}
|
||||||
result := serializeMessages(messages)
|
result := common.SerializeMessages(messages)
|
||||||
|
|
||||||
data, err := json.Marshal(result)
|
data, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -670,7 +671,7 @@ func TestSerializeMessages_WithMedia(t *testing.T) {
|
||||||
messages := []protocoltypes.Message{
|
messages := []protocoltypes.Message{
|
||||||
{Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}},
|
{Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}},
|
||||||
}
|
}
|
||||||
result := serializeMessages(messages)
|
result := common.SerializeMessages(messages)
|
||||||
|
|
||||||
data, _ := json.Marshal(result)
|
data, _ := json.Marshal(result)
|
||||||
var msgs []map[string]any
|
var msgs []map[string]any
|
||||||
|
|
@ -703,7 +704,7 @@ func TestSerializeMessages_MediaWithToolCallID(t *testing.T) {
|
||||||
messages := []protocoltypes.Message{
|
messages := []protocoltypes.Message{
|
||||||
{Role: "tool", Content: "image result", Media: []string{"data:image/png;base64,xyz"}, ToolCallID: "call_1"},
|
{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)
|
data, _ := json.Marshal(result)
|
||||||
var msgs []map[string]any
|
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)
|
data, _ := json.Marshal(result)
|
||||||
raw := string(data)
|
raw := string(data)
|
||||||
|
|
|
||||||
|
|
@ -2,80 +2,289 @@ package skills
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SkillInstaller struct {
|
// GitHubContent represents a file or directory in GitHub API response
|
||||||
workspace string
|
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
|
||||||
return &SkillInstaller{
|
type GitHubRef struct {
|
||||||
workspace: workspace,
|
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 {
|
func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) error {
|
||||||
skillDir := filepath.Join(si.workspace, "skills", filepath.Base(repo))
|
ref, err := parseGitHubRef(repo)
|
||||||
|
if err != nil {
|
||||||
if _, err := os.Stat(skillDir); err == nil {
|
return err
|
||||||
return fmt.Errorf("skill '%s' already exists", filepath.Base(repo))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to fetch skill: %w", err)
|
return fmt.Errorf("failed to fetch skill: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer os.Remove(tmpPath)
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if err := os.MkdirAll(localDir, 0o755); err != nil {
|
||||||
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 {
|
|
||||||
return fmt.Errorf("failed to create skill directory: %w", err)
|
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.
|
// Atomic move from temp to final location.
|
||||||
if err := fileutil.WriteFileAtomic(skillPath, body, 0o600); err != nil {
|
if err := os.Rename(tmpPath, localPath); err != nil {
|
||||||
return fmt.Errorf("failed to write skill file: %w", err)
|
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 {
|
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) {
|
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 {
|
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
|
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 ""
|
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)
|
p, err := filepath.Abs(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -522,3 +522,101 @@ func TestShellTool_CustomAllowPatterns(t *testing.T) {
|
||||||
t.Errorf("'git push upstream main' should still be blocked by deny pattern")
|
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"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -41,43 +43,6 @@ var (
|
||||||
reDDGSnippet = regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
|
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 {
|
type APIKeyPool struct {
|
||||||
keys []string
|
keys []string
|
||||||
current uint32
|
current uint32
|
||||||
|
|
@ -678,7 +643,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
||||||
maxResults := 5
|
maxResults := 5
|
||||||
// Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search
|
// Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search
|
||||||
if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 {
|
if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 {
|
||||||
client, err := createHTTPClient(opts.Proxy, perplexityTimeout)
|
client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err)
|
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
|
maxResults = opts.PerplexityMaxResults
|
||||||
}
|
}
|
||||||
} else if opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 {
|
} else if opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 {
|
||||||
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err)
|
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
|
maxResults = opts.SearXNGMaxResults
|
||||||
}
|
}
|
||||||
} else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 {
|
} else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 {
|
||||||
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err)
|
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
|
maxResults = opts.TavilyMaxResults
|
||||||
}
|
}
|
||||||
} else if opts.DuckDuckGoEnabled {
|
} else if opts.DuckDuckGoEnabled {
|
||||||
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err)
|
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
|
maxResults = opts.DuckDuckGoMaxResults
|
||||||
}
|
}
|
||||||
} else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" {
|
} else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" {
|
||||||
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err)
|
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 {
|
if maxChars <= 0 {
|
||||||
maxChars = defaultMaxChars
|
maxChars = defaultMaxChars
|
||||||
}
|
}
|
||||||
client, err := createHTTPClient(proxy, fetchTimeout)
|
client, err := utils.CreateHTTPClient(proxy, fetchTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err)
|
return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"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) {
|
func TestNewWebFetchToolWithProxy(t *testing.T) {
|
||||||
tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890", testFetchLimit)
|
tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890", testFetchLimit)
|
||||||
if err != nil {
|
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.
|
// GatewayEvent represents a state change event for the gateway process.
|
||||||
type GatewayEvent struct {
|
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"`
|
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.
|
// EventBroadcaster manages SSE client subscriptions and broadcasts events.
|
||||||
|
|
|
||||||
|
|
@ -23,19 +23,36 @@ import (
|
||||||
|
|
||||||
// gateway holds the state for the managed gateway process.
|
// gateway holds the state for the managed gateway process.
|
||||||
var gateway = struct {
|
var gateway = struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
logs *LogBuffer
|
bootDefaultModel string
|
||||||
events *EventBroadcaster
|
runtimeStatus string
|
||||||
|
startupDeadline time.Time
|
||||||
|
logs *LogBuffer
|
||||||
|
events *EventBroadcaster
|
||||||
}{
|
}{
|
||||||
logs: NewLogBuffer(200),
|
runtimeStatus: "stopped",
|
||||||
events: NewEventBroadcaster(),
|
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.
|
// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux.
|
||||||
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
|
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
|
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
|
||||||
mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents)
|
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/logs/clear", h.handleGatewayClearLogs)
|
||||||
mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart)
|
mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart)
|
||||||
mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop)
|
mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop)
|
||||||
|
|
@ -65,7 +82,7 @@ func (h *Handler) TryAutoStartGateway() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pid, err := h.startGatewayLocked()
|
pid, err := h.startGatewayLocked("starting")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to auto-start gateway: %v", err)
|
log.Printf("Failed to auto-start gateway: %v", err)
|
||||||
return
|
return
|
||||||
|
|
@ -131,7 +148,110 @@ func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
|
||||||
return cmd.Process.Signal(syscall.Signal(0)) == nil
|
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
|
// Locate the picoclaw executable
|
||||||
execPath := utils.FindPicoclawBinary()
|
execPath := utils.FindPicoclawBinary()
|
||||||
|
|
||||||
|
|
@ -161,7 +281,7 @@ func (h *Handler) startGatewayLocked() (int, error) {
|
||||||
gateway.logs.Reset()
|
gateway.logs.Reset()
|
||||||
|
|
||||||
// Ensure Pico Channel is configured before starting gateway
|
// 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)
|
log.Printf("Warning: failed to ensure pico channel: %v", err)
|
||||||
// Non-fatal: gateway can still start without pico channel
|
// Non-fatal: gateway can still start without pico channel
|
||||||
}
|
}
|
||||||
|
|
@ -171,11 +291,19 @@ func (h *Handler) startGatewayLocked() (int, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
gateway.cmd = cmd
|
gateway.cmd = cmd
|
||||||
|
gateway.bootDefaultModel = defaultModelName
|
||||||
|
setGatewayRuntimeStatusLocked(initialStatus)
|
||||||
pid := cmd.Process.Pid
|
pid := cmd.Process.Pid
|
||||||
log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)
|
log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)
|
||||||
|
|
||||||
// Broadcast starting event
|
// Broadcast the launch state immediately so clients can reflect it without polling.
|
||||||
gateway.events.Broadcast(GatewayEvent{Status: "starting", PID: pid})
|
gateway.events.Broadcast(GatewayEvent{
|
||||||
|
Status: initialStatus,
|
||||||
|
PID: pid,
|
||||||
|
BootDefaultModel: defaultModelName,
|
||||||
|
ConfigDefaultModel: defaultModelName,
|
||||||
|
RestartRequired: false,
|
||||||
|
})
|
||||||
|
|
||||||
// Capture stdout/stderr in background
|
// Capture stdout/stderr in background
|
||||||
go scanPipe(stdoutPipe, gateway.logs)
|
go scanPipe(stdoutPipe, gateway.logs)
|
||||||
|
|
@ -190,13 +318,23 @@ func (h *Handler) startGatewayLocked() (int, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
gateway.mu.Lock()
|
gateway.mu.Lock()
|
||||||
|
shouldBroadcastStopped := false
|
||||||
if gateway.cmd == cmd {
|
if gateway.cmd == cmd {
|
||||||
gateway.cmd = nil
|
gateway.cmd = nil
|
||||||
|
gateway.bootDefaultModel = ""
|
||||||
|
if gateway.runtimeStatus != "restarting" {
|
||||||
|
setGatewayRuntimeStatusLocked("stopped")
|
||||||
|
shouldBroadcastStopped = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
gateway.mu.Unlock()
|
gateway.mu.Unlock()
|
||||||
|
|
||||||
// Broadcast stopped event
|
if shouldBroadcastStopped {
|
||||||
gateway.events.Broadcast(GatewayEvent{Status: "stopped"})
|
gateway.events.Broadcast(GatewayEvent{
|
||||||
|
Status: "stopped",
|
||||||
|
RestartRequired: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Start a goroutine to probe health and broadcast "running" once ready
|
// Start a goroutine to probe health and broadcast "running" once ready
|
||||||
|
|
@ -219,12 +357,22 @@ func (h *Handler) startGatewayLocked() (int, error) {
|
||||||
healthPort = 18790
|
healthPort = 18790
|
||||||
}
|
}
|
||||||
healthURL := fmt.Sprintf("http://%s/health", net.JoinHostPort(healthHost, strconv.Itoa(healthPort)))
|
healthURL := fmt.Sprintf("http://%s/health", net.JoinHostPort(healthHost, strconv.Itoa(healthPort)))
|
||||||
client := http.Client{Timeout: 1 * time.Second}
|
resp, err := gatewayHealthGet(healthURL, 1*time.Second)
|
||||||
resp, err := client.Get(healthURL)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode == http.StatusOK {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -253,6 +401,7 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
if gateway.cmd != nil && gateway.cmd.Process != nil {
|
if gateway.cmd != nil && gateway.cmd.Process != nil {
|
||||||
gateway.cmd = nil
|
gateway.cmd = nil
|
||||||
|
setGatewayRuntimeStatusLocked("stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
ready, reason, err := h.gatewayStartReady()
|
ready, reason, err := h.gatewayStartReady()
|
||||||
|
|
@ -274,7 +423,7 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pid, err := h.startGatewayLocked()
|
pid, err := h.startGatewayLocked("starting")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -330,30 +479,72 @@ func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) {
|
||||||
//
|
//
|
||||||
// POST /api/gateway/restart
|
// POST /api/gateway/restart
|
||||||
func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
|
||||||
gateway.mu.Lock()
|
ready, reason, err := h.gatewayStartReady()
|
||||||
|
if err != nil {
|
||||||
// Stop existing process if running
|
http.Error(
|
||||||
if gateway.cmd != nil && gateway.cmd.Process != nil {
|
w,
|
||||||
if isCmdProcessAliveLocked(gateway.cmd) {
|
fmt.Sprintf("Failed to validate gateway start conditions: %v", err),
|
||||||
// Process is alive, send SIGTERM
|
http.StatusInternalServerError,
|
||||||
if runtime.GOOS == "windows" {
|
)
|
||||||
gateway.cmd.Process.Kill()
|
return
|
||||||
} else {
|
}
|
||||||
gateway.cmd.Process.Signal(syscall.SIGTERM)
|
if !ready {
|
||||||
}
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
// Wait briefly for it to exit
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
gateway.mu.Unlock()
|
"status": "precondition_failed",
|
||||||
time.Sleep(2 * time.Second)
|
"message": reason,
|
||||||
gateway.mu.Lock()
|
})
|
||||||
}
|
return
|
||||||
gateway.cmd = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gateway.mu.Lock()
|
||||||
|
previousCmd := gateway.cmd
|
||||||
|
setGatewayRuntimeStatusLocked("restarting")
|
||||||
|
gateway.events.Broadcast(GatewayEvent{
|
||||||
|
Status: "restarting",
|
||||||
|
RestartRequired: false,
|
||||||
|
})
|
||||||
gateway.mu.Unlock()
|
gateway.mu.Unlock()
|
||||||
|
|
||||||
// Start fresh via the existing handler
|
if err = stopGatewayProcessForRestart(previousCmd); err != nil {
|
||||||
h.handleGatewayStart(w, r)
|
gateway.mu.Lock()
|
||||||
|
if gateway.cmd == previousCmd {
|
||||||
|
if isCmdProcessAliveLocked(previousCmd) {
|
||||||
|
setGatewayRuntimeStatusLocked("running")
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
// 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
|
// GET /api/gateway/status
|
||||||
func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
|
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{}
|
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
|
// Check process state
|
||||||
gateway.mu.Lock()
|
gateway.mu.Lock()
|
||||||
processAlive := isGatewayProcessAliveLocked()
|
processAlive := isGatewayProcessAliveLocked()
|
||||||
|
bootDefaultModel := ""
|
||||||
if processAlive {
|
if processAlive {
|
||||||
data["pid"] = gateway.cmd.Process.Pid
|
data["pid"] = gateway.cmd.Process.Pid
|
||||||
|
if gateway.bootDefaultModel != "" {
|
||||||
|
data["boot_default_model"] = gateway.bootDefaultModel
|
||||||
|
bootDefaultModel = gateway.bootDefaultModel
|
||||||
|
}
|
||||||
}
|
}
|
||||||
gateway.mu.Unlock()
|
gateway.mu.Unlock()
|
||||||
|
|
||||||
if !processAlive {
|
if !processAlive {
|
||||||
data["gateway_status"] = "stopped"
|
gateway.mu.Lock()
|
||||||
|
data["gateway_status"] = currentGatewayStatusLocked(false)
|
||||||
|
gateway.mu.Unlock()
|
||||||
} else {
|
} else {
|
||||||
// Process is alive — probe its health endpoint
|
// Process is alive — probe its health endpoint
|
||||||
cfg, err := config.LoadConfig(h.configPath)
|
|
||||||
host := "127.0.0.1"
|
host := "127.0.0.1"
|
||||||
port := 18790
|
port := 18790
|
||||||
if err == nil && cfg != nil {
|
if cfgErr == nil && cfg != nil {
|
||||||
host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
|
host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
|
||||||
if cfg.Gateway.Port != 0 {
|
if cfg.Gateway.Port != 0 {
|
||||||
port = cfg.Gateway.Port
|
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)))
|
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||||
client := http.Client{Timeout: 2 * time.Second}
|
resp, err := gatewayHealthGet(url, 2*time.Second)
|
||||||
resp, err := client.Get(url)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
data["gateway_status"] = "starting"
|
gateway.mu.Lock()
|
||||||
|
data["gateway_status"] = currentGatewayStatusLocked(true)
|
||||||
|
gateway.mu.Unlock()
|
||||||
} else {
|
} else {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
gateway.mu.Lock()
|
||||||
|
setGatewayRuntimeStatusLocked("error")
|
||||||
|
gateway.mu.Unlock()
|
||||||
data["gateway_status"] = "error"
|
data["gateway_status"] = "error"
|
||||||
data["status_code"] = resp.StatusCode
|
data["status_code"] = resp.StatusCode
|
||||||
} else {
|
} else {
|
||||||
var healthData map[string]any
|
var healthData map[string]any
|
||||||
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
|
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
|
||||||
|
gateway.mu.Lock()
|
||||||
|
setGatewayRuntimeStatusLocked("error")
|
||||||
|
gateway.mu.Unlock()
|
||||||
data["gateway_status"] = "error"
|
data["gateway_status"] = "error"
|
||||||
} else {
|
} else {
|
||||||
|
gateway.mu.Lock()
|
||||||
|
setGatewayRuntimeStatusLocked("running")
|
||||||
|
gateway.mu.Unlock()
|
||||||
for k, v := range healthData {
|
for k, v := range healthData {
|
||||||
data[k] = v
|
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()
|
ready, reason, readyErr := h.gatewayStartReady()
|
||||||
if readyErr != nil {
|
if readyErr != nil {
|
||||||
data["gateway_start_allowed"] = false
|
data["gateway_start_allowed"] = false
|
||||||
|
|
@ -434,16 +662,22 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append incremental log data
|
return data
|
||||||
appendGatewayLogs(r, 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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(data)
|
json.NewEncoder(w).Encode(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// appendGatewayLogs reads log_offset and log_run_id query params from the request
|
// gatewayLogsData reads log_offset and log_run_id query params from the request
|
||||||
// and populates the response data map with incremental log lines.
|
// and returns incremental log lines.
|
||||||
func appendGatewayLogs(r *http.Request, data map[string]any) {
|
func gatewayLogsData(r *http.Request) map[string]any {
|
||||||
|
data := map[string]any{}
|
||||||
clientOffset := 0
|
clientOffset := 0
|
||||||
clientRunID := -1
|
clientRunID := -1
|
||||||
|
|
||||||
|
|
@ -465,7 +699,7 @@ func appendGatewayLogs(r *http.Request, data map[string]any) {
|
||||||
data["logs"] = []string{}
|
data["logs"] = []string{}
|
||||||
data["log_total"] = 0
|
data["log_total"] = 0
|
||||||
data["log_run_id"] = 0
|
data["log_run_id"] = 0
|
||||||
return
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// If runID changed, reset offset to get all logs from new run
|
// 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["logs"] = lines
|
||||||
data["log_total"] = total
|
data["log_total"] = total
|
||||||
data["log_run_id"] = runID
|
data["log_run_id"] = runID
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGatewayEvents serves an SSE stream of gateway state change events.
|
// 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.
|
// currentGatewayStatus returns the current gateway status as a JSON string.
|
||||||
func (h *Handler) currentGatewayStatus() string {
|
func (h *Handler) currentGatewayStatus() string {
|
||||||
gateway.mu.Lock()
|
data := h.gatewayStatusData()
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded, _ := json.Marshal(data)
|
encoded, _ := json.Marshal(data)
|
||||||
return string(encoded)
|
return string(encoded)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,76 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/web/backend/utils"
|
"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) {
|
func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
h := NewHandler(configPath)
|
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) {
|
func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
h := NewHandler(configPath)
|
h := NewHandler(configPath)
|
||||||
|
|
@ -353,33 +816,36 @@ func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {
|
||||||
t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID)
|
t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID)
|
||||||
}
|
}
|
||||||
|
|
||||||
statusRec := httptest.NewRecorder()
|
logsRec := httptest.NewRecorder()
|
||||||
statusReq := httptest.NewRequest(
|
logsReq := httptest.NewRequest(
|
||||||
http.MethodGet,
|
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,
|
nil,
|
||||||
)
|
)
|
||||||
mux.ServeHTTP(statusRec, statusReq)
|
mux.ServeHTTP(logsRec, logsReq)
|
||||||
|
|
||||||
if statusRec.Code != http.StatusOK {
|
if logsRec.Code != http.StatusOK {
|
||||||
t.Fatalf("status code = %d, want %d", statusRec.Code, http.StatusOK)
|
t.Fatalf("logs code = %d, want %d", logsRec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusBody map[string]any
|
var logsBody map[string]any
|
||||||
if err := json.Unmarshal(statusRec.Body.Bytes(), &statusBody); err != nil {
|
if err := json.Unmarshal(logsRec.Body.Bytes(), &logsBody); err != nil {
|
||||||
t.Fatalf("unmarshal status response: %v", err)
|
t.Fatalf("unmarshal logs response: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logs, ok := statusBody["logs"].([]any)
|
logs, ok := logsBody["logs"].([]any)
|
||||||
if !ok {
|
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 {
|
if len(logs) != 0 {
|
||||||
t.Fatalf("logs len = %d, want 0", len(logs))
|
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)
|
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) {
|
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
|
// ensurePicoChannel enables the Pico channel with sane defaults if it isn't
|
||||||
// enables it with sensible defaults if not. Returns true if config was changed.
|
// already configured. Returns true when the config was modified.
|
||||||
func (h *Handler) ensurePicoChannel() (bool, error) {
|
//
|
||||||
|
// 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)
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("failed to load config: %w", err)
|
return false, fmt.Errorf("failed to load config: %w", err)
|
||||||
|
|
@ -85,14 +90,9 @@ func (h *Handler) ensurePicoChannel() (bool, error) {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cfg.Channels.Pico.AllowTokenQuery {
|
// Seed origins from the request instead of hardcoding ports.
|
||||||
cfg.Channels.Pico.AllowTokenQuery = true
|
if len(cfg.Channels.Pico.AllowOrigins) == 0 && callerOrigin != "" {
|
||||||
changed = true
|
cfg.Channels.Pico.AllowOrigins = []string{callerOrigin}
|
||||||
}
|
|
||||||
|
|
||||||
// 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{"*"}
|
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,7 +109,7 @@ func (h *Handler) ensurePicoChannel() (bool, error) {
|
||||||
//
|
//
|
||||||
// POST /api/pico/setup
|
// POST /api/pico/setup
|
||||||
func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
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"
|
"embed"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -14,6 +15,13 @@ var frontendFS embed.FS
|
||||||
|
|
||||||
// registerEmbedRoutes sets up the HTTP handler to serve the embedded frontend files
|
// registerEmbedRoutes sets up the HTTP handler to serve the embedded frontend files
|
||||||
func registerEmbedRoutes(mux *http.ServeMux) {
|
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
|
// Attempt to get the subdirectory 'dist' where Vite usually builds
|
||||||
subFS, err := fs.Sub(frontendFS, "dist")
|
subFS, err := fs.Sub(frontendFS, "dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,18 @@
|
||||||
"@tabler/icons-react": "^3.38.0",
|
"@tabler/icons-react": "^3.38.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@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",
|
"@tanstack/react-router-devtools": "^1.163.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.20",
|
||||||
"i18next": "^25.8.14",
|
"i18next": "^25.8.14",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jotai": "^2.18.0",
|
"jotai": "^2.18.1",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-i18next": "^16.5.4",
|
"react-i18next": "^16.5.8",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-textarea-autosize": "^8.5.9",
|
"react-textarea-autosize": "^8.5.9",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"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
|
specifier: ^5.90.21
|
||||||
version: 5.90.21(react@19.2.4)
|
version: 5.90.21(react@19.2.4)
|
||||||
'@tanstack/react-router':
|
'@tanstack/react-router':
|
||||||
specifier: ^1.163.3
|
specifier: ^1.167.0
|
||||||
version: 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
'@tanstack/react-router-devtools':
|
'@tanstack/react-router-devtools':
|
||||||
specifier: ^1.163.3
|
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:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
|
|
@ -33,8 +33,8 @@ importers:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.19
|
specifier: ^1.11.20
|
||||||
version: 1.11.19
|
version: 1.11.20
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^25.8.14
|
specifier: ^25.8.14
|
||||||
version: 25.8.14(typescript@5.9.3)
|
version: 25.8.14(typescript@5.9.3)
|
||||||
|
|
@ -42,8 +42,8 @@ importers:
|
||||||
specifier: ^8.2.1
|
specifier: ^8.2.1
|
||||||
version: 8.2.1
|
version: 8.2.1
|
||||||
jotai:
|
jotai:
|
||||||
specifier: ^2.18.0
|
specifier: ^2.18.1
|
||||||
version: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4)
|
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:
|
radix-ui:
|
||||||
specifier: ^1.4.3
|
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)
|
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
|
specifier: ^19.2.0
|
||||||
version: 19.2.4(react@19.2.4)
|
version: 19.2.4(react@19.2.4)
|
||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: ^16.5.4
|
specifier: ^16.5.8
|
||||||
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)
|
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:
|
react-markdown:
|
||||||
specifier: ^10.1.0
|
specifier: ^10.1.0
|
||||||
version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
|
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)
|
version: 0.5.19(tailwindcss@4.2.1)
|
||||||
'@tanstack/router-plugin':
|
'@tanstack/router-plugin':
|
||||||
specifier: ^1.164.0
|
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':
|
'@trivago/prettier-plugin-sort-imports':
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.2(prettier@3.8.1)
|
version: 6.0.2(prettier@3.8.1)
|
||||||
|
|
@ -109,8 +109,8 @@ importers:
|
||||||
specifier: ^8.56.1
|
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)
|
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':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^5.1.1
|
specifier: ^5.2.0
|
||||||
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))
|
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:
|
eslint:
|
||||||
specifier: ^9.39.1
|
specifier: ^9.39.1
|
||||||
version: 9.39.3(jiti@2.6.1)
|
version: 9.39.3(jiti@2.6.1)
|
||||||
|
|
@ -1587,15 +1587,15 @@ packages:
|
||||||
'@tanstack/router-core':
|
'@tanstack/router-core':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@tanstack/react-router@1.163.3':
|
'@tanstack/react-router@1.167.0':
|
||||||
resolution: {integrity: sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q==}
|
resolution: {integrity: sha512-U7CamtXjuC8ixg1c32Rj/4A2OFBnjtMLdbgbyOGHrFHE7ULWS/yhnZLVXff0QSyn6qF92Oecek9mDMHCaTnB2Q==}
|
||||||
engines: {node: '>=20.19'}
|
engines: {node: '>=20.19'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=18.0.0 || >=19.0.0'
|
react: '>=18.0.0 || >=19.0.0'
|
||||||
react-dom: '>=18.0.0 || >=19.0.0'
|
react-dom: '>=18.0.0 || >=19.0.0'
|
||||||
|
|
||||||
'@tanstack/react-store@0.9.1':
|
'@tanstack/react-store@0.9.2':
|
||||||
resolution: {integrity: sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==}
|
resolution: {integrity: sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
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
|
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==}
|
resolution: {integrity: sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA==}
|
||||||
engines: {node: '>=20.19'}
|
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':
|
'@tanstack/router-devtools-core@1.163.3':
|
||||||
resolution: {integrity: sha512-FPi64IP0PT1IkoeyGmsD6JoOVOYAb85VCH0mUbSdD90yV0+1UB6oT+D7K27GXkp7SXMJN3mBEjU5rKnNnmSCIw==}
|
resolution: {integrity: sha512-FPi64IP0PT1IkoeyGmsD6JoOVOYAb85VCH0mUbSdD90yV0+1UB6oT+D7K27GXkp7SXMJN3mBEjU5rKnNnmSCIw==}
|
||||||
engines: {node: '>=20.19'}
|
engines: {node: '>=20.19'}
|
||||||
|
|
@ -1646,6 +1650,9 @@ packages:
|
||||||
'@tanstack/store@0.9.1':
|
'@tanstack/store@0.9.1':
|
||||||
resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==}
|
resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==}
|
||||||
|
|
||||||
|
'@tanstack/store@0.9.2':
|
||||||
|
resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==}
|
||||||
|
|
||||||
'@tanstack/virtual-file-routes@1.161.4':
|
'@tanstack/virtual-file-routes@1.161.4':
|
||||||
resolution: {integrity: sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==}
|
resolution: {integrity: sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==}
|
||||||
engines: {node: '>=20.19'}
|
engines: {node: '>=20.19'}
|
||||||
|
|
@ -1790,11 +1797,11 @@ packages:
|
||||||
'@ungap/structured-clone@1.3.0':
|
'@ungap/structured-clone@1.3.0':
|
||||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||||
|
|
||||||
'@vitejs/plugin-react@5.1.4':
|
'@vitejs/plugin-react@5.2.0':
|
||||||
resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==}
|
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
peerDependencies:
|
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:
|
accepts@2.0.0:
|
||||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||||
|
|
@ -2060,8 +2067,8 @@ packages:
|
||||||
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
|
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
dayjs@1.11.19:
|
dayjs@1.11.20:
|
||||||
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
|
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
|
|
@ -2648,8 +2655,8 @@ packages:
|
||||||
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
|
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
isbot@5.1.35:
|
isbot@5.1.36:
|
||||||
resolution: {integrity: sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==}
|
resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
|
|
@ -2669,8 +2676,8 @@ packages:
|
||||||
jose@6.1.3:
|
jose@6.1.3:
|
||||||
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||||
|
|
||||||
jotai@2.18.0:
|
jotai@2.18.1:
|
||||||
resolution: {integrity: sha512-XI38kGWAvtxAZ+cwHcTgJsd+kJOJGf3OfL4XYaXWZMZ7IIY8e53abpIHvtVn1eAgJ5dlgwlGFnP4psrZ/vZbtA==}
|
resolution: {integrity: sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': '>=7.0.0'
|
'@babel/core': '>=7.0.0'
|
||||||
|
|
@ -3323,8 +3330,8 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.4
|
react: ^19.2.4
|
||||||
|
|
||||||
react-i18next@16.5.4:
|
react-i18next@16.5.8:
|
||||||
resolution: {integrity: sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==}
|
resolution: {integrity: sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
i18next: '>= 25.6.2'
|
i18next: '>= 25.6.2'
|
||||||
react: '>= 16.8.0'
|
react: '>= 16.8.0'
|
||||||
|
|
@ -3476,10 +3483,20 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
seroval: ^1.0
|
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:
|
seroval@1.5.0:
|
||||||
resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==}
|
resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
seroval@1.5.1:
|
||||||
|
resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
serve-static@2.2.1:
|
serve-static@2.2.1:
|
||||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
@ -5365,31 +5382,31 @@ snapshots:
|
||||||
'@tanstack/query-core': 5.90.20
|
'@tanstack/query-core': 5.90.20
|
||||||
react: 19.2.4
|
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:
|
dependencies:
|
||||||
'@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)
|
||||||
'@tanstack/router-devtools-core': 1.163.3(@tanstack/router-core@1.163.3)(csstype@3.2.3)
|
'@tanstack/router-devtools-core': 1.163.3(@tanstack/router-core@1.167.0)(csstype@3.2.3)
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@tanstack/router-core': 1.163.3
|
'@tanstack/router-core': 1.167.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- csstype
|
- 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:
|
dependencies:
|
||||||
'@tanstack/history': 1.161.4
|
'@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/react-store': 0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
'@tanstack/router-core': 1.163.3
|
'@tanstack/router-core': 1.167.0
|
||||||
isbot: 5.1.35
|
isbot: 5.1.36
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
tiny-invariant: 1.3.3
|
tiny-invariant: 1.3.3
|
||||||
tiny-warning: 1.0.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:
|
dependencies:
|
||||||
'@tanstack/store': 0.9.1
|
'@tanstack/store': 0.9.2
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
use-sync-external-store: 1.6.0(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-invariant: 1.3.3
|
||||||
tiny-warning: 1.0.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:
|
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
|
clsx: 2.1.1
|
||||||
goober: 2.1.18(csstype@3.2.3)
|
goober: 2.1.18(csstype@3.2.3)
|
||||||
tiny-invariant: 1.3.3
|
tiny-invariant: 1.3.3
|
||||||
|
|
@ -5426,7 +5453,7 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
'@babel/plugin-syntax-jsx': 7.28.6(@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
|
unplugin: 2.3.11
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
optionalDependencies:
|
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)
|
vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
@ -5463,6 +5490,8 @@ snapshots:
|
||||||
|
|
||||||
'@tanstack/store@0.9.1': {}
|
'@tanstack/store@0.9.1': {}
|
||||||
|
|
||||||
|
'@tanstack/store@0.9.2': {}
|
||||||
|
|
||||||
'@tanstack/virtual-file-routes@1.161.4': {}
|
'@tanstack/virtual-file-routes@1.161.4': {}
|
||||||
|
|
||||||
'@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)':
|
'@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)':
|
||||||
|
|
@ -5641,7 +5670,7 @@ snapshots:
|
||||||
|
|
||||||
'@ungap/structured-clone@1.3.0': {}
|
'@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:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@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: {}
|
data-uri-to-buffer@4.0.1: {}
|
||||||
|
|
||||||
dayjs@1.11.19: {}
|
dayjs@1.11.20: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -6489,7 +6518,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-inside-container: 1.0.0
|
is-inside-container: 1.0.0
|
||||||
|
|
||||||
isbot@5.1.35: {}
|
isbot@5.1.36: {}
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
|
|
@ -6501,7 +6530,7 @@ snapshots:
|
||||||
|
|
||||||
jose@6.1.3: {}
|
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:
|
optionalDependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
'@babel/template': 7.28.6
|
'@babel/template': 7.28.6
|
||||||
|
|
@ -7310,7 +7339,7 @@ snapshots:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
scheduler: 0.27.0
|
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:
|
dependencies:
|
||||||
'@babel/runtime': 7.28.6
|
'@babel/runtime': 7.28.6
|
||||||
html-parse-stringify: 3.0.1
|
html-parse-stringify: 3.0.1
|
||||||
|
|
@ -7517,8 +7546,14 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
seroval: 1.5.0
|
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.0: {}
|
||||||
|
|
||||||
|
seroval@1.5.1: {}
|
||||||
|
|
||||||
serve-static@2.2.1:
|
serve-static@2.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
encodeurl: 2.0.0
|
encodeurl: 2.0.0
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
// API client for gateway process management.
|
// API client for gateway process management.
|
||||||
|
|
||||||
interface GatewayStatusResponse {
|
interface GatewayStatusResponse {
|
||||||
gateway_status: "running" | "starting" | "stopped" | "error"
|
gateway_status: "running" | "starting" | "restarting" | "stopped" | "error"
|
||||||
gateway_start_allowed?: boolean
|
gateway_start_allowed?: boolean
|
||||||
gateway_start_reason?: string
|
gateway_start_reason?: string
|
||||||
|
gateway_restart_required?: boolean
|
||||||
pid?: number
|
pid?: number
|
||||||
|
boot_default_model?: string
|
||||||
|
config_default_model?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GatewayLogsResponse {
|
||||||
logs?: string[]
|
logs?: string[]
|
||||||
log_total?: number
|
log_total?: number
|
||||||
log_run_id?: number
|
log_run_id?: number
|
||||||
[key: string]: unknown
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GatewayActionResponse {
|
interface GatewayActionResponse {
|
||||||
|
|
@ -28,10 +34,14 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
return res.json() as 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_offset?: number
|
||||||
log_run_id?: number
|
log_run_id?: number
|
||||||
}): Promise<GatewayStatusResponse> {
|
}): Promise<GatewayLogsResponse> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (options?.log_offset !== undefined) {
|
if (options?.log_offset !== undefined) {
|
||||||
params.set("log_offset", options.log_offset.toString())
|
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())
|
params.set("log_run_id", options.log_run_id.toString())
|
||||||
}
|
}
|
||||||
const queryString = params.toString() ? `?${params.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> {
|
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 }),
|
body: JSON.stringify({ model_name: modelName }),
|
||||||
})
|
})
|
||||||
|
|
||||||
void refreshGatewayState()
|
await refreshGatewayState()
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
IconMoon,
|
IconMoon,
|
||||||
IconPlayerPlay,
|
IconPlayerPlay,
|
||||||
IconPower,
|
IconPower,
|
||||||
|
IconRefresh,
|
||||||
IconSun,
|
IconSun,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
import { Link } from "@tanstack/react-router"
|
import { Link } from "@tanstack/react-router"
|
||||||
|
|
@ -31,6 +32,11 @@ import {
|
||||||
} from "@/components/ui/dropdown-menu.tsx"
|
} from "@/components/ui/dropdown-menu.tsx"
|
||||||
import { Separator } from "@/components/ui/separator.tsx"
|
import { Separator } from "@/components/ui/separator.tsx"
|
||||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
import { useGateway } from "@/hooks/use-gateway.ts"
|
import { useGateway } from "@/hooks/use-gateway.ts"
|
||||||
import { useTheme } from "@/hooks/use-theme.ts"
|
import { useTheme } from "@/hooks/use-theme.ts"
|
||||||
|
|
||||||
|
|
@ -41,27 +47,35 @@ export function AppHeader() {
|
||||||
state: gwState,
|
state: gwState,
|
||||||
loading: gwLoading,
|
loading: gwLoading,
|
||||||
canStart,
|
canStart,
|
||||||
|
restartRequired,
|
||||||
start,
|
start,
|
||||||
|
restart,
|
||||||
stop,
|
stop,
|
||||||
} = useGateway()
|
} = useGateway()
|
||||||
|
|
||||||
const isRunning = gwState === "running"
|
const isRunning = gwState === "running"
|
||||||
const isStarting = gwState === "starting"
|
const isStarting = gwState === "starting"
|
||||||
|
const isRestarting = gwState === "restarting"
|
||||||
const isStopped = gwState === "stopped" || gwState === "unknown"
|
const isStopped = gwState === "stopped" || gwState === "unknown"
|
||||||
const showNotConnectedHint =
|
const showNotConnectedHint =
|
||||||
canStart && (gwState === "stopped" || gwState === "error")
|
!isRestarting && canStart && (gwState === "stopped" || gwState === "error")
|
||||||
|
|
||||||
const [showStopDialog, setShowStopDialog] = React.useState(false)
|
const [showStopDialog, setShowStopDialog] = React.useState(false)
|
||||||
|
|
||||||
const handleGatewayToggle = () => {
|
const handleGatewayToggle = () => {
|
||||||
if (gwLoading || (!isRunning && !canStart)) return
|
if (gwLoading || isRestarting || (!isRunning && !canStart)) return
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
setShowStopDialog(true)
|
setShowStopDialog(true)
|
||||||
} else {
|
} else {
|
||||||
start()
|
void start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleGatewayRestart = () => {
|
||||||
|
if (gwLoading || isRestarting || !restartRequired || !canStart) return
|
||||||
|
void restart()
|
||||||
|
}
|
||||||
|
|
||||||
const confirmStop = () => {
|
const confirmStop = () => {
|
||||||
setShowStopDialog(false)
|
setShowStopDialog(false)
|
||||||
stop()
|
stop()
|
||||||
|
|
@ -115,35 +129,67 @@ export function AppHeader() {
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
<div className="text-muted-foreground flex items-center gap-1 text-sm font-medium md:gap-2">
|
<div className="text-muted-foreground flex items-center gap-1 text-sm font-medium md:gap-2">
|
||||||
|
{restartRequired && (
|
||||||
|
<Tooltip delayDuration={700}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
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 */}
|
{/* Gateway Start/Stop */}
|
||||||
<Button
|
{isRunning ? (
|
||||||
variant={isStarting ? "secondary" : "default"}
|
<Tooltip delayDuration={700}>
|
||||||
size="sm"
|
<TooltipTrigger asChild>
|
||||||
className={`h-8 gap-2 px-3 ${
|
<Button
|
||||||
isRunning
|
variant="destructive"
|
||||||
? "bg-destructive/10 text-destructive hover:bg-destructive/20"
|
size="icon-sm"
|
||||||
: isStopped
|
className="size-8"
|
||||||
? "bg-green-500 text-white hover:bg-green-600"
|
onClick={handleGatewayToggle}
|
||||||
: ""
|
disabled={gwLoading}
|
||||||
}`}
|
aria-label={t("header.gateway.action.stop")}
|
||||||
onClick={handleGatewayToggle}
|
>
|
||||||
disabled={gwLoading || isStarting || (!isRunning && !canStart)}
|
<IconPower className="h-4 w-4 opacity-80" />
|
||||||
>
|
</Button>
|
||||||
{gwLoading || isStarting ? (
|
</TooltipTrigger>
|
||||||
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
|
<TooltipContent>{t("header.gateway.action.stop")}</TooltipContent>
|
||||||
) : isRunning ? (
|
</Tooltip>
|
||||||
<IconPower className="h-4 w-4 opacity-80" />
|
) : (
|
||||||
) : (
|
<Button
|
||||||
<IconPlayerPlay className="h-4 w-4 opacity-80" />
|
variant={isStarting || isRestarting ? "secondary" : "default"}
|
||||||
)}
|
size="sm"
|
||||||
<span className="text-xs font-semibold">
|
className={`h-8 gap-2 px-3 ${
|
||||||
{isRunning
|
isStopped ? "bg-green-500 text-white hover:bg-green-600" : ""
|
||||||
? t("header.gateway.action.stop")
|
}`}
|
||||||
: isStarting
|
onClick={handleGatewayToggle}
|
||||||
? t("header.gateway.status.starting")
|
disabled={gwLoading || isStarting || isRestarting || !canStart}
|
||||||
: t("header.gateway.action.start")}
|
>
|
||||||
</span>
|
{gwLoading || isStarting || isRestarting ? (
|
||||||
</Button>
|
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
|
||||||
|
) : (
|
||||||
|
<IconPlayerPlay className="h-4 w-4 opacity-80" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-semibold">
|
||||||
|
{isRestarting
|
||||||
|
? t("header.gateway.status.restarting")
|
||||||
|
: isStarting
|
||||||
|
? t("header.gateway.status.starting")
|
||||||
|
: t("header.gateway.action.start")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator
|
<Separator
|
||||||
className="mx-4 my-2 hidden md:block"
|
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 { useGateway } from "@/hooks/use-gateway"
|
||||||
import { usePicoChat } from "@/hooks/use-pico-chat"
|
import { usePicoChat } from "@/hooks/use-pico-chat"
|
||||||
import { useSessionHistory } from "@/hooks/use-session-history"
|
import { useSessionHistory } from "@/hooks/use-session-history"
|
||||||
|
import { hydrateActiveSession } from "@/lib/pico-chat-controller"
|
||||||
|
|
||||||
export function ChatPage() {
|
export function ChatPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true)
|
const [isAtBottom, setIsAtBottom] = useState(true)
|
||||||
|
const [hasScrolled, setHasScrolled] = useState(false)
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -56,14 +58,26 @@ export function ChatPage() {
|
||||||
onDeletedActiveSession: newChat,
|
onDeletedActiveSession: newChat,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
const syncScrollState = (element: HTMLDivElement) => {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
|
const { scrollTop, scrollHeight, clientHeight } = element
|
||||||
|
setHasScrolled(scrollTop > 0)
|
||||||
setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)
|
setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
syncScrollState(e.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAtBottom && scrollRef.current) {
|
void hydrateActiveSession()
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
if (isAtBottom) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
syncScrollState(scrollRef.current)
|
||||||
}
|
}
|
||||||
}, [messages, isTyping, isAtBottom])
|
}, [messages, isTyping, isAtBottom])
|
||||||
|
|
||||||
|
|
@ -77,6 +91,9 @@ export function ChatPage() {
|
||||||
<div className="bg-background/95 flex h-full flex-col">
|
<div className="bg-background/95 flex h-full flex-col">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t("navigation.chat")}
|
title={t("navigation.chat")}
|
||||||
|
className={`transition-shadow ${
|
||||||
|
hasScrolled ? "shadow-sm" : "shadow-none"
|
||||||
|
}`}
|
||||||
titleExtra={
|
titleExtra={
|
||||||
hasConfiguredModels && (
|
hasConfiguredModels && (
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
|
|
@ -90,7 +107,7 @@ export function ChatPage() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={newChat}
|
onClick={newChat}
|
||||||
className="h-9 gap-2"
|
className="h-9 gap-2"
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export function ModelSelector({
|
||||||
>
|
>
|
||||||
<SelectValue placeholder={t("chat.noModel")} />
|
<SelectValue placeholder={t("chat.noModel")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent position="popper" align="start">
|
||||||
{apiKeyModels.length > 0 && (
|
{apiKeyModels.length > 0 && (
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel>{t("chat.modelGroup.apikey")}</SelectLabel>
|
<SelectLabel>{t("chat.modelGroup.apikey")}</SelectLabel>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export function SessionHistoryMenu({
|
||||||
return (
|
return (
|
||||||
<DropdownMenu onOpenChange={onOpenChange}>
|
<DropdownMenu onOpenChange={onOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<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" />
|
<IconHistory className="size-4" />
|
||||||
<span className="hidden sm:inline">{t("chat.history")}</span>
|
<span className="hidden sm:inline">{t("chat.history")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ export function EditModelSheet({
|
||||||
: undefined,
|
: undefined,
|
||||||
thinking_level: form.thinkingLevel || undefined,
|
thinking_level: form.thinkingLevel || undefined,
|
||||||
})
|
})
|
||||||
if (setAsDefault) {
|
if (setAsDefault && !model.is_default) {
|
||||||
await setDefaultModel(model.model_name)
|
await setDefaultModel(model.model_name)
|
||||||
}
|
}
|
||||||
onSaved()
|
onSaved()
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,8 @@ export function ModelsPage() {
|
||||||
}, [fetchModels])
|
}, [fetchModels])
|
||||||
|
|
||||||
const handleSetDefault = async (model: ModelInfo) => {
|
const handleSetDefault = async (model: ModelInfo) => {
|
||||||
|
if (model.is_default) return
|
||||||
|
|
||||||
setSettingDefaultIndex(model.index)
|
setSettingDefaultIndex(model.index)
|
||||||
try {
|
try {
|
||||||
await setDefaultModel(model.model_name)
|
await setDefaultModel(model.model_name)
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,28 @@ import { IconMenu2 } from "@tabler/icons-react"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface PageHeaderProps {
|
interface PageHeaderProps {
|
||||||
title: string
|
title: string
|
||||||
titleExtra?: ReactNode
|
titleExtra?: ReactNode
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageHeader({ title, titleExtra, children }: PageHeaderProps) {
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
titleExtra,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: PageHeaderProps) {
|
||||||
return (
|
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">
|
<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">
|
<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 />
|
<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"
|
import { type ModelInfo, getModels, setDefaultModel } from "@/api/models"
|
||||||
|
|
||||||
|
|
@ -20,6 +20,7 @@ function isLocalModel(model: ModelInfo): boolean {
|
||||||
export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
||||||
const [modelList, setModelList] = useState<ModelInfo[]>([])
|
const [modelList, setModelList] = useState<ModelInfo[]>([])
|
||||||
const [defaultModelName, setDefaultModelName] = useState("")
|
const [defaultModelName, setDefaultModelName] = useState("")
|
||||||
|
const setDefaultRequestIdRef = useRef(0)
|
||||||
|
|
||||||
const loadModels = useCallback(async () => {
|
const loadModels = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -41,17 +42,28 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
||||||
return () => clearTimeout(timerId)
|
return () => clearTimeout(timerId)
|
||||||
}, [isConnected, loadModels])
|
}, [isConnected, loadModels])
|
||||||
|
|
||||||
const handleSetDefault = useCallback(async (modelName: string) => {
|
const handleSetDefault = useCallback(
|
||||||
try {
|
async (modelName: string) => {
|
||||||
await setDefaultModel(modelName)
|
if (modelName === defaultModelName) return
|
||||||
setDefaultModelName(modelName)
|
const requestId = ++setDefaultRequestIdRef.current
|
||||||
setModelList((prev) =>
|
|
||||||
prev.map((m) => ({ ...m, is_default: m.model_name === modelName })),
|
try {
|
||||||
)
|
await setDefaultModel(modelName)
|
||||||
} catch (err) {
|
const data = await getModels()
|
||||||
console.error("Failed to set default model:", err)
|
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(
|
const hasConfiguredModels = useMemo(
|
||||||
() => modelList.some((m) => m.configured),
|
() => modelList.some((m) => m.configured),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useAtomValue } from "jotai"
|
import { useAtomValue } from "jotai"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
import { clearGatewayLogs, getGatewayStatus } from "@/api/gateway"
|
import { clearGatewayLogs, getGatewayLogs } from "@/api/gateway"
|
||||||
import { gatewayAtom } from "@/store/gateway"
|
import { gatewayAtom } from "@/store/gateway"
|
||||||
|
|
||||||
export function useGatewayLogs() {
|
export function useGatewayLogs() {
|
||||||
|
|
@ -37,7 +37,7 @@ export function useGatewayLogs() {
|
||||||
const fetchLogs = async () => {
|
const fetchLogs = async () => {
|
||||||
if (
|
if (
|
||||||
!mounted ||
|
!mounted ||
|
||||||
(gateway.status !== "running" && gateway.status !== "starting")
|
!["running", "starting", "restarting"].includes(gateway.status)
|
||||||
) {
|
) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
timeout = setTimeout(fetchLogs, 1000)
|
timeout = setTimeout(fetchLogs, 1000)
|
||||||
|
|
@ -49,7 +49,7 @@ export function useGatewayLogs() {
|
||||||
const requestToken = syncTokenRef.current
|
const requestToken = syncTokenRef.current
|
||||||
const requestOffset = logOffsetRef.current
|
const requestOffset = logOffsetRef.current
|
||||||
const requestRunId = logRunIdRef.current
|
const requestRunId = logRunIdRef.current
|
||||||
const data = await getGatewayStatus({
|
const data = await getGatewayLogs({
|
||||||
log_offset: requestOffset,
|
log_offset: requestOffset,
|
||||||
log_run_id: requestRunId,
|
log_run_id: requestRunId,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,30 @@
|
||||||
import { useAtom } from "jotai"
|
import { useAtomValue } from "jotai"
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type GatewayStatusResponse,
|
type GatewayStatusResponse,
|
||||||
getGatewayStatus,
|
getGatewayStatus,
|
||||||
|
restartGateway,
|
||||||
startGateway,
|
startGateway,
|
||||||
stopGateway,
|
stopGateway,
|
||||||
} from "@/api/gateway"
|
} from "@/api/gateway"
|
||||||
import { gatewayAtom } from "@/store"
|
import {
|
||||||
|
applyGatewayStatusToStore,
|
||||||
|
gatewayAtom,
|
||||||
|
updateGatewayStore,
|
||||||
|
} from "@/store"
|
||||||
|
|
||||||
// Global variable to ensure we only have one SSE connection
|
// Global variable to ensure we only have one SSE connection
|
||||||
let sseInitialized = false
|
let sseInitialized = false
|
||||||
|
|
||||||
export function useGateway() {
|
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 [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const applyGatewayStatus = useCallback(
|
const applyGatewayStatus = useCallback((data: GatewayStatusResponse) => {
|
||||||
(data: GatewayStatusResponse) => {
|
applyGatewayStatusToStore(data)
|
||||||
setGateway((prev) => ({
|
}, [])
|
||||||
...prev,
|
|
||||||
status: data.gateway_status ?? "unknown",
|
|
||||||
canStart: data.gateway_start_allowed ?? true,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
[setGateway],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Initialize global SSE connection once
|
// Initialize global SSE connection once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -35,9 +34,10 @@ export function useGateway() {
|
||||||
getGatewayStatus()
|
getGatewayStatus()
|
||||||
.then((data) => applyGatewayStatus(data))
|
.then((data) => applyGatewayStatus(data))
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setGateway({
|
updateGatewayStore({
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
canStart: true,
|
canStart: true,
|
||||||
|
restartRequired: false,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -59,14 +59,7 @@ export function useGateway() {
|
||||||
data.gateway_status ||
|
data.gateway_status ||
|
||||||
typeof data.gateway_start_allowed === "boolean"
|
typeof data.gateway_start_allowed === "boolean"
|
||||||
) {
|
) {
|
||||||
setGateway((prev) => ({
|
applyGatewayStatus(data)
|
||||||
...prev,
|
|
||||||
status: data.gateway_status ?? prev.status,
|
|
||||||
canStart:
|
|
||||||
typeof data.gateway_start_allowed === "boolean"
|
|
||||||
? data.gateway_start_allowed
|
|
||||||
: prev.canStart,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|
@ -75,7 +68,9 @@ export function useGateway() {
|
||||||
|
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
// EventSource will auto-reconnect
|
// EventSource will auto-reconnect
|
||||||
setGateway((prev) => ({ ...prev, status: "unknown" }))
|
updateGatewayStore((prev) =>
|
||||||
|
prev.status === "restarting" ? {} : { status: "unknown" },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -83,7 +78,7 @@ export function useGateway() {
|
||||||
es.close()
|
es.close()
|
||||||
sseInitialized = false
|
sseInitialized = false
|
||||||
}
|
}
|
||||||
}, [applyGatewayStatus, setGateway])
|
}, [applyGatewayStatus])
|
||||||
|
|
||||||
const start = useCallback(async () => {
|
const start = useCallback(async () => {
|
||||||
if (!canStart) return
|
if (!canStart) return
|
||||||
|
|
@ -92,19 +87,19 @@ export function useGateway() {
|
||||||
try {
|
try {
|
||||||
await startGateway()
|
await startGateway()
|
||||||
// SSE will push the real state changes, but set optimistic state
|
// SSE will push the real state changes, but set optimistic state
|
||||||
setGateway((prev) => ({ ...prev, status: "starting" }))
|
updateGatewayStore({ status: "starting" })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to start gateway:", err)
|
console.error("Failed to start gateway:", err)
|
||||||
try {
|
try {
|
||||||
const status = await getGatewayStatus()
|
const status = await getGatewayStatus()
|
||||||
applyGatewayStatus(status)
|
applyGatewayStatus(status)
|
||||||
} catch {
|
} catch {
|
||||||
setGateway((prev) => ({ ...prev, status: "unknown" }))
|
updateGatewayStore({ status: "unknown" })
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [applyGatewayStatus, canStart, setGateway])
|
}, [applyGatewayStatus, canStart])
|
||||||
|
|
||||||
const stop = useCallback(async () => {
|
const stop = useCallback(async () => {
|
||||||
setLoading(true)
|
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 dayjs from "dayjs"
|
||||||
import { useAtomValue } from "jotai"
|
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 {
|
||||||
import { getSessionHistory } from "@/api/sessions"
|
newChatSession,
|
||||||
import { gatewayAtom } from "@/store"
|
sendChatMessage,
|
||||||
|
switchChatSession,
|
||||||
// Pico Protocol message types
|
} from "@/lib/pico-chat-controller"
|
||||||
interface PicoMessage {
|
import { chatAtom } from "@/store/chat"
|
||||||
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)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const UNIX_MS_THRESHOLD = 1e12
|
const UNIX_MS_THRESHOLD = 1e12
|
||||||
|
|
||||||
|
|
@ -102,285 +57,16 @@ export function formatMessageTime(dateRaw: number | string | Date): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePicoChat() {
|
export function usePicoChat() {
|
||||||
const { t } = useTranslation()
|
const { messages, connectionState, isTyping, activeSessionId } =
|
||||||
const { status: gatewayState } = useAtomValue(gatewayAtom)
|
useAtomValue(chatAtom)
|
||||||
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])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
connectionState,
|
connectionState,
|
||||||
isTyping,
|
isTyping,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
sendMessage,
|
sendMessage: sendChatMessage,
|
||||||
switchSession,
|
switchSession: switchChatSession,
|
||||||
newChat,
|
newChat: newChatSession,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,11 +58,14 @@
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"start": "Start Gateway",
|
"start": "Start Gateway",
|
||||||
"stop": "Stop Gateway"
|
"stop": "Stop Gateway",
|
||||||
|
"restart": "Restart Gateway"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"starting": "Starting Gateway..."
|
"starting": "Starting Gateway...",
|
||||||
}
|
"restarting": "Restarting Gateway..."
|
||||||
|
},
|
||||||
|
"restartRequired": "Model changes require a gateway restart to take effect."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
|
|
||||||
|
|
@ -58,11 +58,14 @@
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"start": "启动服务",
|
"start": "启动服务",
|
||||||
"stop": "停止服务"
|
"stop": "停止服务",
|
||||||
|
"restart": "重启服务"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"starting": "服务启动中..."
|
"starting": "服务启动中...",
|
||||||
}
|
"restarting": "服务重启中..."
|
||||||
|
},
|
||||||
|
"restartRequired": "切换默认模型后需要重启服务才能生效。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"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 { Outlet, createRootRoute } from "@tanstack/react-router"
|
||||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
import { AppLayout } from "@/components/app-layout"
|
import { AppLayout } from "@/components/app-layout"
|
||||||
|
import { initializeChatStore } from "@/lib/pico-chat-controller"
|
||||||
|
|
||||||
const RootLayout = () => {
|
const RootLayout = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
initializeChatStore()
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<Outlet />
|
<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 =
|
export type GatewayState =
|
||||||
| "running"
|
| "running"
|
||||||
| "starting"
|
| "starting"
|
||||||
|
| "restarting"
|
||||||
| "stopped"
|
| "stopped"
|
||||||
| "error"
|
| "error"
|
||||||
| "unknown"
|
| "unknown"
|
||||||
|
|
@ -12,19 +13,54 @@ export type GatewayState =
|
||||||
export interface GatewayStoreState {
|
export interface GatewayStoreState {
|
||||||
status: GatewayState
|
status: GatewayState
|
||||||
canStart: boolean
|
canStart: boolean
|
||||||
|
restartRequired: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type GatewayStorePatch = Partial<GatewayStoreState>
|
||||||
|
|
||||||
|
const DEFAULT_GATEWAY_STATE: GatewayStoreState = {
|
||||||
|
status: "unknown",
|
||||||
|
canStart: true,
|
||||||
|
restartRequired: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global atom for gateway state
|
// Global atom for gateway state
|
||||||
export const gatewayAtom = atom<GatewayStoreState>({
|
export const gatewayAtom = atom<GatewayStoreState>(DEFAULT_GATEWAY_STATE)
|
||||||
status: "unknown",
|
|
||||||
canStart: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
function applyGatewayStatusToStore(data: GatewayStatusResponse) {
|
function normalizeGatewayStoreState(
|
||||||
getDefaultStore().set(gatewayAtom, (prev) => ({
|
prev: GatewayStoreState,
|
||||||
...prev,
|
patch: GatewayStorePatch,
|
||||||
status: data.gateway_status ?? "unknown",
|
) {
|
||||||
canStart: data.gateway_start_allowed ?? true,
|
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()
|
const status = await getGatewayStatus()
|
||||||
applyGatewayStatusToStore(status)
|
applyGatewayStatusToStore(status)
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort refresh only; keep current state on error.
|
updateGatewayStore(DEFAULT_GATEWAY_STATE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from "./gateway"
|
export * from "./gateway"
|
||||||
|
export * from "./chat"
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,59 @@
|
||||||
---
|
---
|
||||||
name: weather
|
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
|
homepage: https://wttr.in/:help
|
||||||
metadata: {"nanobot":{"emoji":"🌤️","requires":{"bins":["curl"]}}}
|
metadata: {"nanobot":{"emoji":"🌤️","requires":{"bins":["curl"]}}}
|
||||||
---
|
---
|
||||||
|
|
||||||
# Weather
|
# 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
|
```bash
|
||||||
curl -s "wttr.in/London?format=3"
|
curl -s "https://wttr.in/London?format=%l:+%c+%t+%h+%w"
|
||||||
# Output: London: ⛅️ +8°C
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Compact format:
|
Chinese city example:
|
||||||
```bash
|
```bash
|
||||||
curl -s "wttr.in/London?format=%l:+%c+%t+%h+%w"
|
curl -s "https://wttr.in/%E6%88%90%E9%83%BD?format=%l:+%c+%t+%h+%w"
|
||||||
# Output: London: ⛅️ +8°C 71% ↙5km/h
|
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
|
```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:
|
Tips:
|
||||||
- URL-encode spaces: `wttr.in/New+York`
|
- URL-encode spaces: `New York` -> `New+York`
|
||||||
- Airport codes: `wttr.in/JFK`
|
- URL-encode non-ASCII text before sending the request
|
||||||
- Units: `?m` (metric) `?u` (USCS)
|
- Use `?m` for metric units and `?u` for US units
|
||||||
- Today only: `?1` · Current only: `?0`
|
|
||||||
- PNG: `curl -s "wttr.in/Berlin.png" -o /tmp/weather.png`
|
|
||||||
|
|
||||||
## 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
|
```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
|
Docs: https://open-meteo.com/en/docs
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue