Merge branch 'main' into dockerfile
This commit is contained in:
commit
175956f408
40 changed files with 992 additions and 329 deletions
|
|
@ -5,7 +5,7 @@ version: 2
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
- go generate ./cmd/picoclaw
|
- go generate ./cmd/picoclaw/...
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
|
|
@ -39,7 +39,9 @@ builds:
|
||||||
|
|
||||||
dockers_v2:
|
dockers_v2:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
dockerfile: Dockerfile.goreleaser
|
dockerfile: docker/Dockerfile.goreleaser
|
||||||
|
extra_files:
|
||||||
|
- docker/entrypoint.sh
|
||||||
ids:
|
ids:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
images:
|
images:
|
||||||
|
|
@ -73,7 +75,6 @@ nfpms:
|
||||||
package_name: picoclaw
|
package_name: picoclaw
|
||||||
file_name_template: >-
|
file_name_template: >-
|
||||||
{{ .PackageName }}_
|
{{ .PackageName }}_
|
||||||
{{- .Version }}_
|
|
||||||
{{- if eq .Arch "amd64" }}x86_64
|
{{- if eq .Arch "amd64" }}x86_64
|
||||||
{{- else if eq .Arch "arm64" }}aarch64
|
{{- else if eq .Arch "arm64" }}aarch64
|
||||||
{{- else if eq .Arch "arm" }}armv{{ .Arm }}
|
{{- else if eq .Arch "arm" }}armv{{ .Arm }}
|
||||||
|
|
|
||||||
2
Makefile
2
Makefile
|
|
@ -44,6 +44,8 @@ ifeq ($(UNAME_S),Linux)
|
||||||
ARCH=amd64
|
ARCH=amd64
|
||||||
else ifeq ($(UNAME_M),aarch64)
|
else ifeq ($(UNAME_M),aarch64)
|
||||||
ARCH=arm64
|
ARCH=arm64
|
||||||
|
else ifeq ($(UNAME_M),armv81)
|
||||||
|
ARCH=arm64
|
||||||
else ifeq ($(UNAME_M),loongarch64)
|
else ifeq ($(UNAME_M),loongarch64)
|
||||||
ARCH=loong64
|
ARCH=loong64
|
||||||
else ifeq ($(UNAME_M),riscv64)
|
else ifeq ($(UNAME_M),riscv64)
|
||||||
|
|
|
||||||
49
README.fr.md
49
README.fr.md
|
|
@ -164,39 +164,43 @@ Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installe
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Configurez vos clés API
|
# 2. Premier lancement — génère docker/data/config.json puis s'arrête
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc.
|
# Le conteneur affiche "First-run setup complete." puis s'arrête.
|
||||||
|
|
||||||
# 3. Compiler & Démarrer
|
# 3. Configurez vos clés API
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # Clés API du fournisseur, tokens de bot, etc.
|
||||||
|
|
||||||
|
# 4. Démarrer
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`.
|
> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Voir les logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
# 4. Voir les logs
|
# 6. Arrêter
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
|
|
||||||
# 5. Arrêter
|
|
||||||
docker compose --profile gateway down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Mode Agent (exécution unique)
|
### Mode Agent (exécution unique)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Poser une question
|
# Poser une question
|
||||||
docker compose run --rm picoclaw-agent -m "Combien font 2+2 ?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Combien font 2+2 ?"
|
||||||
|
|
||||||
# Mode interactif
|
# Mode interactif
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Recompiler
|
### Mettre à jour
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Démarrage Rapide
|
### 🚀 Démarrage Rapide
|
||||||
|
|
@ -221,6 +225,7 @@ picoclaw onboard
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -252,6 +257,9 @@ picoclaw onboard
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Nouveau** : Le format de configuration `model_list` permet d'ajouter des fournisseurs sans modifier le code. Voir [Configuration de Modèle](#configuration-de-modèle-model_list) pour plus de détails.
|
||||||
|
> `request_timeout` est optionnel et s'exprime en secondes. S'il est omis ou défini à `<= 0`, PicoClaw utilise le délai d'expiration par défaut (120s).
|
||||||
|
|
||||||
**3. Obtenir des Clés API**
|
**3. Obtenir des Clés API**
|
||||||
|
|
||||||
* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
|
|
@ -979,6 +987,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
||||||
```
|
```
|
||||||
> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
|
> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
|
||||||
|
|
||||||
|
**Proxy/API personnalisée**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Équilibrage de Charge
|
#### Équilibrage de Charge
|
||||||
|
|
||||||
Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :
|
Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :
|
||||||
|
|
|
||||||
49
README.ja.md
49
README.ja.md
|
|
@ -126,39 +126,43 @@ Docker Compose を使えば、ローカルにインストールせずに PicoCla
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. API キーを設定
|
# 2. 初回起動 — docker/data/config.json を自動生成して終了
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キーを設定
|
# コンテナが "First-run setup complete." を表示して停止します。
|
||||||
|
|
||||||
# 3. ビルドと起動
|
# 3. API キーを設定
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # プロバイダー API キー、Bot トークンなどを設定
|
||||||
|
|
||||||
|
# 4. 起動
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。
|
> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. ログ確認
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
# 4. ログ確認
|
# 6. 停止
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
|
|
||||||
# 5. 停止
|
|
||||||
docker compose --profile gateway down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent モード(ワンショット)
|
### Agent モード(ワンショット)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 質問を投げる
|
# 質問を投げる
|
||||||
docker compose run --rm picoclaw-agent -m "What is 2+2?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
|
||||||
|
|
||||||
# インタラクティブモード
|
# インタラクティブモード
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### リビルド
|
### アップデート
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 クイックスタート(ネイティブ)
|
### 🚀 クイックスタート(ネイティブ)
|
||||||
|
|
@ -183,6 +187,7 @@ picoclaw onboard
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -221,6 +226,9 @@ picoclaw onboard
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **新機能**: `model_list` 形式により、プロバイダーをコード変更なしで追加できます。詳細は [モデル設定](#モデル設定-model_list) を参照してください。
|
||||||
|
> `request_timeout` は任意の秒単位設定です。省略または `<= 0` の場合、PicoClaw はデフォルトのタイムアウト(120秒)を使用します。
|
||||||
|
|
||||||
**3. API キーの取得**
|
**3. API キーの取得**
|
||||||
|
|
||||||
- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
|
|
@ -918,6 +926,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
```
|
```
|
||||||
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
|
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
|
||||||
|
|
||||||
|
**カスタムプロキシ/API**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### ロードバランシング
|
#### ロードバランシング
|
||||||
|
|
||||||
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
|
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
|
||||||
|
|
|
||||||
41
README.md
41
README.md
|
|
@ -167,39 +167,43 @@ You can also run PicoClaw using Docker Compose without installing anything local
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Set your API keys
|
# 2. First run — auto-generates docker/data/config.json then exits
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc.
|
# The container prints "First-run setup complete." and stops.
|
||||||
|
|
||||||
# 3. Build & Start
|
# 3. Set your API keys
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # Set provider API keys, bot tokens, etc.
|
||||||
|
|
||||||
|
# 4. Start
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
|
> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Check logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
# 4. Check logs
|
# 6. Stop
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
|
|
||||||
# 5. Stop
|
|
||||||
docker compose --profile gateway down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent Mode (One-shot)
|
### Agent Mode (One-shot)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ask a question
|
# Ask a question
|
||||||
docker compose run --rm picoclaw-agent -m "What is 2+2?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
|
||||||
|
|
||||||
# Interactive mode
|
# Interactive mode
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rebuild
|
### Update
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Quick Start
|
### 🚀 Quick Start
|
||||||
|
|
@ -232,7 +236,8 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "your-api-key"
|
"api_key": "your-api-key",
|
||||||
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
|
@ -262,6 +267,7 @@ picoclaw onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
|
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
|
||||||
|
> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).
|
||||||
|
|
||||||
**3. Get API Keys**
|
**3. Get API Keys**
|
||||||
|
|
||||||
|
|
@ -915,7 +921,8 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,39 +165,43 @@ Você tambêm pode rodar o PicoClaw usando Docker Compose sem instalar nada loca
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Configure suas API keys
|
# 2. Primeiro uso — gera docker/data/config.json automaticamente e para
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc.
|
# O contêiner exibe "First-run setup complete." e para.
|
||||||
|
|
||||||
# 3. Build & Iniciar
|
# 3. Configure suas API keys
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # Chaves de API do provedor, tokens de bot, etc.
|
||||||
|
|
||||||
|
# 4. Iniciar
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`.
|
> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Ver logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
# 4. Ver logs
|
# 6. Parar
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
|
|
||||||
# 5. Parar
|
|
||||||
docker compose --profile gateway down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Modo Agente (Execução única)
|
### Modo Agente (Execução única)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Fazer uma pergunta
|
# Fazer uma pergunta
|
||||||
docker compose run --rm picoclaw-agent -m "Quanto e 2+2?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Quanto e 2+2?"
|
||||||
|
|
||||||
# Modo interativo
|
# Modo interativo
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rebuild
|
### Atualizar
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Início Rápido
|
### 🚀 Início Rápido
|
||||||
|
|
@ -222,6 +226,7 @@ picoclaw onboard
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -246,6 +251,9 @@ picoclaw onboard
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Novo**: O formato de configuração `model_list` permite adicionar provedores sem alterar código. Veja [Configuração de Modelo](#configuração-de-modelo-model_list) para detalhes.
|
||||||
|
> `request_timeout` é opcional e usa segundos. Se omitido ou definido como `<= 0`, o PicoClaw usa o timeout padrão (120s).
|
||||||
|
|
||||||
**3. Obter API Keys**
|
**3. Obter API Keys**
|
||||||
|
|
||||||
* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
|
|
@ -973,6 +981,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
||||||
```
|
```
|
||||||
> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
|
> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
|
||||||
|
|
||||||
|
**Proxy/API personalizada**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Balanceamento de Carga
|
#### Balanceamento de Carga
|
||||||
|
|
||||||
Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
|
Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
|
||||||
|
|
|
||||||
49
README.vi.md
49
README.vi.md
|
|
@ -145,39 +145,43 @@ Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cà
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Thiết lập API Key
|
# 2. Lần chạy đầu tiên — tự tạo docker/data/config.json rồi dừng lại
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v.
|
# Container hiển thị "First-run setup complete." rồi tự dừng.
|
||||||
|
|
||||||
# 3. Build & Khởi động
|
# 3. Thiết lập API Key
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # API key của provider, bot token, v.v.
|
||||||
|
|
||||||
|
# 4. Khởi động
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`.
|
> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Xem logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
# 4. Xem logs
|
# 6. Dừng
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
|
|
||||||
# 5. Dừng
|
|
||||||
docker compose --profile gateway down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Chế độ Agent (chạy một lần)
|
### Chế độ Agent (chạy một lần)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Đặt câu hỏi
|
# Đặt câu hỏi
|
||||||
docker compose run --rm picoclaw-agent -m "2+2 bằng mấy?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 bằng mấy?"
|
||||||
|
|
||||||
# Chế độ tương tác
|
# Chế độ tương tác
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build lại
|
### Cập nhật
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Bắt đầu nhanh
|
### 🚀 Bắt đầu nhanh
|
||||||
|
|
@ -202,6 +206,7 @@ picoclaw onboard
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -220,6 +225,9 @@ picoclaw onboard
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Mới**: Định dạng cấu hình `model_list` cho phép thêm nhà cung cấp mà không cần thay đổi mã nguồn. Xem [Cấu hình Mô hình](#cấu-hình-mô-hình-model_list) để biết chi tiết.
|
||||||
|
> `request_timeout` là tùy chọn và dùng đơn vị giây. Nếu bỏ qua hoặc đặt `<= 0`, PicoClaw sẽ dùng timeout mặc định (120s).
|
||||||
|
|
||||||
**3. Lấy API Key**
|
**3. Lấy API Key**
|
||||||
|
|
||||||
* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
|
|
@ -944,6 +952,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
```
|
```
|
||||||
> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
|
> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
|
||||||
|
|
||||||
|
**Proxy/API tùy chỉnh**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Cân bằng Tải tải
|
#### Cân bằng Tải tải
|
||||||
|
|
||||||
Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
|
Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
|
||||||
|
|
|
||||||
45
README.zh.md
45
README.zh.md
|
|
@ -166,41 +166,43 @@ make install
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. 设置 API Key
|
# 2. 首次运行 — 自动生成 docker/data/config.json 后退出
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等
|
# 容器打印 "First-run setup complete." 后自动停止
|
||||||
|
|
||||||
# 3. 构建并启动
|
# 3. 填写 API Key 等配置
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # 设置 provider API key、Bot Token 等
|
||||||
|
|
||||||
|
# 4. 正式启动
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
**Docker 用户**: 默认情况下, Gateway监听 `127.0.0.1`,这使得这个端口未暴露到容器外。如果你需要通过端口映射访问健康检查接口, 请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。
|
> **Docker 用户**: 默认情况下, Gateway 监听 `127.0.0.1`,该端口不会暴露到容器外。如果需要通过端口映射访问健康检查接口,请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。
|
||||||
|
|
||||||
# 4. 查看日志
|
```bash
|
||||||
docker compose logs -f picoclaw-gateway
|
# 5. 查看日志
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
# 5. 停止
|
|
||||||
docker compose --profile gateway down
|
|
||||||
|
|
||||||
|
# 6. 停止
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent 模式 (一次性运行)
|
### Agent 模式 (一次性运行)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 提问
|
# 提问
|
||||||
docker compose run --rm picoclaw-agent -m "2+2 等于几?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 等于几?"
|
||||||
|
|
||||||
# 交互模式
|
# 交互模式
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 重新构建
|
### 更新镜像
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 快速开始
|
### 🚀 快速开始
|
||||||
|
|
@ -234,7 +236,8 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "your-api-key"
|
"api_key": "your-api-key",
|
||||||
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
|
@ -263,6 +266,7 @@ picoclaw onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
|
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
|
||||||
|
> `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。
|
||||||
|
|
||||||
**3. 获取 API Key**
|
**3. 获取 API Key**
|
||||||
|
|
||||||
|
|
@ -550,7 +554,8 @@ Agent 读取 HEARTBEAT.md
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 147 KiB |
|
|
@ -5,6 +5,8 @@ ARG TARGETPLATFORM
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["picoclaw"]
|
RUN chmod +x /entrypoint.sh
|
||||||
CMD ["gateway"]
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
services:
|
services:
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# PicoClaw Agent (one-shot query)
|
# PicoClaw Agent (one-shot query)
|
||||||
# docker compose run --rm picoclaw-agent -m "Hello"
|
# docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello"
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-agent:
|
picoclaw-agent:
|
||||||
build:
|
image: docker.io/sipeed/picoclaw:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: picoclaw-agent
|
container_name: picoclaw-agent
|
||||||
profiles:
|
profiles:
|
||||||
- agent
|
- agent
|
||||||
|
|
@ -22,14 +20,12 @@ services:
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# PicoClaw Gateway (Long-running Bot)
|
# PicoClaw Gateway (Long-running Bot)
|
||||||
# docker compose up picoclaw-gateway
|
# docker compose -f docker/docker-compose.yml up picoclaw-gateway
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-gateway:
|
picoclaw-gateway:
|
||||||
build:
|
image: "docker.io/sipeed/picoclaw:${PICOCLAW_IMAGE_VERSION:-latest}"
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: picoclaw-gateway
|
container_name: picoclaw-gateway
|
||||||
restart: unless-stopped
|
restart: on-failure
|
||||||
profiles:
|
profiles:
|
||||||
- gateway
|
- gateway
|
||||||
# Uncomment to access host network; leave commented unless needed.
|
# Uncomment to access host network; leave commented unless needed.
|
||||||
15
docker/entrypoint.sh
Normal file
15
docker/entrypoint.sh
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# First-run: neither config nor workspace exists.
|
||||||
|
# If config.json is already mounted but workspace is missing we skip onboard to
|
||||||
|
# avoid the interactive "Overwrite? (y/n)" prompt hanging in a non-TTY container.
|
||||||
|
if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.json" ]; then
|
||||||
|
picoclaw onboard
|
||||||
|
echo ""
|
||||||
|
echo "First-run setup complete."
|
||||||
|
echo "Edit ${HOME}/.picoclaw/config.json (add your API key, etc.) then restart the container."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec picoclaw gateway "$@"
|
||||||
|
|
@ -117,6 +117,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
||||||
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
|
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
|
||||||
| `rpm` | No | Requests per minute limit |
|
| `rpm` | No | Requests per minute limit |
|
||||||
| `max_tokens_field` | No | Field name for max tokens |
|
| `max_tokens_field` | No | Field name for max tokens |
|
||||||
|
| `request_timeout` | No | HTTP request timeout in seconds; `<=0` uses default `120s` |
|
||||||
|
|
||||||
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
|
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MemoryStore manages persistent memory for the agent.
|
// MemoryStore manages persistent memory for the agent.
|
||||||
|
|
@ -58,7 +60,9 @@ func (ms *MemoryStore) ReadLongTerm() string {
|
||||||
|
|
||||||
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
|
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
|
||||||
func (ms *MemoryStore) WriteLongTerm(content string) error {
|
func (ms *MemoryStore) WriteLongTerm(content string) error {
|
||||||
return os.WriteFile(ms.memoryFile, []byte(content), 0o644)
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
// Using 0o600 (owner read/write only) for secure default permissions.
|
||||||
|
return fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadToday reads today's daily note.
|
// ReadToday reads today's daily note.
|
||||||
|
|
@ -78,7 +82,9 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
||||||
|
|
||||||
// Ensure month directory exists
|
// Ensure month directory exists
|
||||||
monthDir := filepath.Dir(todayFile)
|
monthDir := filepath.Dir(todayFile)
|
||||||
os.MkdirAll(monthDir, 0o755)
|
if err := os.MkdirAll(monthDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
var existingContent string
|
var existingContent string
|
||||||
if data, err := os.ReadFile(todayFile); err == nil {
|
if data, err := os.ReadFile(todayFile); err == nil {
|
||||||
|
|
@ -95,7 +101,8 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
||||||
newContent = existingContent + "\n" + content
|
newContent = existingContent + "\n" + content
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(todayFile, []byte(newContent), 0o644)
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRecentDailyNotes returns daily notes from the last N days.
|
// GetRecentDailyNotes returns daily notes from the last N days.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthCredential struct {
|
type AuthCredential struct {
|
||||||
|
|
@ -63,16 +65,13 @@ func LoadStore() (*AuthStore, error) {
|
||||||
|
|
||||||
func SaveStore(store *AuthStore) error {
|
func SaveStore(store *AuthStore) error {
|
||||||
path := authFilePath()
|
path := authFilePath()
|
||||||
dir := filepath.Dir(path)
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.MarshalIndent(store, "", " ")
|
data, err := json.MarshalIndent(store, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(path, data, 0o600)
|
|
||||||
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCredential(provider string) (*AuthCredential, error) {
|
func GetCredential(provider string) (*AuthCredential, error) {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,19 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/voice"
|
"github.com/sipeed/picoclaw/pkg/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
|
||||||
|
reBlockquote = regexp.MustCompile(`^>\s*(.*)$`)
|
||||||
|
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||||
|
reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`)
|
||||||
|
reBoldUnder = regexp.MustCompile(`__(.+?)__`)
|
||||||
|
reItalic = regexp.MustCompile(`_([^_]+)_`)
|
||||||
|
reStrike = regexp.MustCompile(`~~(.+?)~~`)
|
||||||
|
reListItem = regexp.MustCompile(`^[-*]\s+`)
|
||||||
|
reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
|
||||||
|
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
||||||
|
)
|
||||||
|
|
||||||
type TelegramChannel struct {
|
type TelegramChannel struct {
|
||||||
*BaseChannel
|
*BaseChannel
|
||||||
bot *telego.Bot
|
bot *telego.Bot
|
||||||
|
|
@ -431,19 +444,18 @@ func markdownToTelegramHTML(text string) string {
|
||||||
inlineCodes := extractInlineCodes(text)
|
inlineCodes := extractInlineCodes(text)
|
||||||
text = inlineCodes.text
|
text = inlineCodes.text
|
||||||
|
|
||||||
text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1")
|
text = reHeading.ReplaceAllString(text, "$1")
|
||||||
|
|
||||||
text = regexp.MustCompile(`^>\s*(.*)$`).ReplaceAllString(text, "$1")
|
text = reBlockquote.ReplaceAllString(text, "$1")
|
||||||
|
|
||||||
text = escapeHTML(text)
|
text = escapeHTML(text)
|
||||||
|
|
||||||
text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, `<a href="$2">$1</a>`)
|
text = reLink.ReplaceAllString(text, `<a href="$2">$1</a>`)
|
||||||
|
|
||||||
text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "<b>$1</b>")
|
text = reBoldStar.ReplaceAllString(text, "<b>$1</b>")
|
||||||
|
|
||||||
text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "<b>$1</b>")
|
text = reBoldUnder.ReplaceAllString(text, "<b>$1</b>")
|
||||||
|
|
||||||
reItalic := regexp.MustCompile(`_([^_]+)_`)
|
|
||||||
text = reItalic.ReplaceAllStringFunc(text, func(s string) string {
|
text = reItalic.ReplaceAllStringFunc(text, func(s string) string {
|
||||||
match := reItalic.FindStringSubmatch(s)
|
match := reItalic.FindStringSubmatch(s)
|
||||||
if len(match) < 2 {
|
if len(match) < 2 {
|
||||||
|
|
@ -452,9 +464,9 @@ func markdownToTelegramHTML(text string) string {
|
||||||
return "<i>" + match[1] + "</i>"
|
return "<i>" + match[1] + "</i>"
|
||||||
})
|
})
|
||||||
|
|
||||||
text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "<s>$1</s>")
|
text = reStrike.ReplaceAllString(text, "<s>$1</s>")
|
||||||
|
|
||||||
text = regexp.MustCompile(`^[-*]\s+`).ReplaceAllString(text, "• ")
|
text = reListItem.ReplaceAllString(text, "• ")
|
||||||
|
|
||||||
for i, code := range inlineCodes.codes {
|
for i, code := range inlineCodes.codes {
|
||||||
escaped := escapeHTML(code)
|
escaped := escapeHTML(code)
|
||||||
|
|
@ -479,8 +491,7 @@ type codeBlockMatch struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractCodeBlocks(text string) codeBlockMatch {
|
func extractCodeBlocks(text string) codeBlockMatch {
|
||||||
re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
|
matches := reCodeBlock.FindAllStringSubmatch(text, -1)
|
||||||
matches := re.FindAllStringSubmatch(text, -1)
|
|
||||||
|
|
||||||
codes := make([]string, 0, len(matches))
|
codes := make([]string, 0, len(matches))
|
||||||
for _, match := range matches {
|
for _, match := range matches {
|
||||||
|
|
@ -488,7 +499,7 @@ func extractCodeBlocks(text string) codeBlockMatch {
|
||||||
}
|
}
|
||||||
|
|
||||||
i := 0
|
i := 0
|
||||||
text = re.ReplaceAllStringFunc(text, func(m string) string {
|
text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
|
||||||
placeholder := fmt.Sprintf("\x00CB%d\x00", i)
|
placeholder := fmt.Sprintf("\x00CB%d\x00", i)
|
||||||
i++
|
i++
|
||||||
return placeholder
|
return placeholder
|
||||||
|
|
@ -503,8 +514,7 @@ type inlineCodeMatch struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractInlineCodes(text string) inlineCodeMatch {
|
func extractInlineCodes(text string) inlineCodeMatch {
|
||||||
re := regexp.MustCompile("`([^`]+)`")
|
matches := reInlineCode.FindAllStringSubmatch(text, -1)
|
||||||
matches := re.FindAllStringSubmatch(text, -1)
|
|
||||||
|
|
||||||
codes := make([]string, 0, len(matches))
|
codes := make([]string, 0, len(matches))
|
||||||
for _, match := range matches {
|
for _, match := range matches {
|
||||||
|
|
@ -512,7 +522,7 @@ func extractInlineCodes(text string) inlineCodeMatch {
|
||||||
}
|
}
|
||||||
|
|
||||||
i := 0
|
i := 0
|
||||||
text = re.ReplaceAllStringFunc(text, func(m string) string {
|
text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {
|
||||||
placeholder := fmt.Sprintf("\x00IC%d\x00", i)
|
placeholder := fmt.Sprintf("\x00IC%d\x00", i)
|
||||||
i++
|
i++
|
||||||
return placeholder
|
return placeholder
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// rrCounter is a global counter for round-robin load balancing across models.
|
// rrCounter is a global counter for round-robin load balancing across models.
|
||||||
|
|
@ -371,11 +372,12 @@ func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderConfig struct {
|
type ProviderConfig struct {
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
|
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
|
||||||
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
|
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
|
||||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
|
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
|
||||||
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
|
RequestTimeout int `json:"request_timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT"`
|
||||||
ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
|
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
|
||||||
|
ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenAIProviderConfig struct {
|
type OpenAIProviderConfig struct {
|
||||||
|
|
@ -406,6 +408,7 @@ type ModelConfig struct {
|
||||||
// Optional optimizations
|
// Optional optimizations
|
||||||
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
||||||
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
||||||
|
RequestTimeout int `json:"request_timeout,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if the ModelConfig has all required fields.
|
// Validate checks if the ModelConfig has all required fields.
|
||||||
|
|
@ -553,12 +556,8 @@ func SaveConfig(path string, cfg *Config) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
dir := filepath.Dir(path)
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return os.WriteFile(path, data, 0o600)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) WorkspacePath() string {
|
func (c *Config) WorkspacePath() string {
|
||||||
|
|
|
||||||
|
|
@ -413,3 +413,12 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) {
|
||||||
t.Fatalf("Tools.Web.Proxy = %q, want %q", cfg.Tools.Web.Proxy, "http://127.0.0.1:7890")
|
t.Fatalf("Tools.Web.Proxy = %q, want %q", cfg.Tools.Web.Proxy, "http://127.0.0.1:7890")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDefaultConfig_DMScope verifies the default dm_scope value
|
||||||
|
func TestDefaultConfig_DMScope(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
if cfg.Session.DMScope != "per-channel-peer" {
|
||||||
|
t.Errorf("Session.DMScope = %q, want 'per-channel-peer'", cfg.Session.DMScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
Bindings: []AgentBinding{},
|
Bindings: []AgentBinding{},
|
||||||
Session: SessionConfig{
|
Session: SessionConfig{
|
||||||
DMScope: "main",
|
DMScope: "per-channel-peer",
|
||||||
},
|
},
|
||||||
Channels: ChannelsConfig{
|
Channels: ChannelsConfig{
|
||||||
WhatsApp: WhatsAppConfig{
|
WhatsApp: WhatsAppConfig{
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,13 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
return ModelConfig{}, false
|
return ModelConfig{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "openai",
|
ModelName: "openai",
|
||||||
Model: "openai/gpt-5.2",
|
Model: "openai/gpt-5.2",
|
||||||
APIKey: p.OpenAI.APIKey,
|
APIKey: p.OpenAI.APIKey,
|
||||||
APIBase: p.OpenAI.APIBase,
|
APIBase: p.OpenAI.APIBase,
|
||||||
Proxy: p.OpenAI.Proxy,
|
Proxy: p.OpenAI.Proxy,
|
||||||
AuthMethod: p.OpenAI.AuthMethod,
|
RequestTimeout: p.OpenAI.RequestTimeout,
|
||||||
|
AuthMethod: p.OpenAI.AuthMethod,
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -77,12 +78,13 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
return ModelConfig{}, false
|
return ModelConfig{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "anthropic",
|
ModelName: "anthropic",
|
||||||
Model: "anthropic/claude-sonnet-4.6",
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
APIKey: p.Anthropic.APIKey,
|
APIKey: p.Anthropic.APIKey,
|
||||||
APIBase: p.Anthropic.APIBase,
|
APIBase: p.Anthropic.APIBase,
|
||||||
Proxy: p.Anthropic.Proxy,
|
Proxy: p.Anthropic.Proxy,
|
||||||
AuthMethod: p.Anthropic.AuthMethod,
|
RequestTimeout: p.Anthropic.RequestTimeout,
|
||||||
|
AuthMethod: p.Anthropic.AuthMethod,
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -94,11 +96,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
return ModelConfig{}, false
|
return ModelConfig{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "openrouter",
|
ModelName: "openrouter",
|
||||||
Model: "openrouter/auto",
|
Model: "openrouter/auto",
|
||||||
APIKey: p.OpenRouter.APIKey,
|
APIKey: p.OpenRouter.APIKey,
|
||||||
APIBase: p.OpenRouter.APIBase,
|
APIBase: p.OpenRouter.APIBase,
|
||||||
Proxy: p.OpenRouter.Proxy,
|
Proxy: p.OpenRouter.Proxy,
|
||||||
|
RequestTimeout: p.OpenRouter.RequestTimeout,
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -110,11 +113,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
return ModelConfig{}, false
|
return ModelConfig{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "groq",
|
ModelName: "groq",
|
||||||
Model: "groq/llama-3.1-70b-versatile",
|
Model: "groq/llama-3.1-70b-versatile",
|
||||||
APIKey: p.Groq.APIKey,
|
APIKey: p.Groq.APIKey,
|
||||||
APIBase: p.Groq.APIBase,
|
APIBase: p.Groq.APIBase,
|
||||||
Proxy: p.Groq.Proxy,
|
Proxy: p.Groq.Proxy,
|
||||||
|
RequestTimeout: p.Groq.RequestTimeout,
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -126,11 +130,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
return ModelConfig{}, false
|
return ModelConfig{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "zhipu",
|
ModelName: "zhipu",
|
||||||
Model: "zhipu/glm-4",
|
Model: "zhipu/glm-4",
|
||||||
APIKey: p.Zhipu.APIKey,
|
APIKey: p.Zhipu.APIKey,
|
||||||
APIBase: p.Zhipu.APIBase,
|
APIBase: p.Zhipu.APIBase,
|
||||||
Proxy: p.Zhipu.Proxy,
|
Proxy: p.Zhipu.Proxy,
|
||||||
|
RequestTimeout: p.Zhipu.RequestTimeout,
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -142,11 +147,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
return ModelConfig{}, false
|
return ModelConfig{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "vllm",
|
ModelName: "vllm",
|
||||||
Model: "vllm/auto",
|
Model: "vllm/auto",
|
||||||
APIKey: p.VLLM.APIKey,
|
APIKey: p.VLLM.APIKey,
|
||||||
APIBase: p.VLLM.APIBase,
|
APIBase: p.VLLM.APIBase,
|
||||||
Proxy: p.VLLM.Proxy,
|
Proxy: p.VLLM.Proxy,
|
||||||
|
RequestTimeout: p.VLLM.RequestTimeout,
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -158,11 +164,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
return ModelConfig{}, false
|
return ModelConfig{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "gemini",
|
ModelName: "gemini",
|
||||||
Model: "gemini/gemini-pro",
|
Model: "gemini/gemini-pro",
|
||||||
APIKey: p.Gemini.APIKey,
|
APIKey: p.Gemini.APIKey,
|
||||||
APIBase: p.Gemini.APIBase,
|
APIBase: p.Gemini.APIBase,
|
||||||
Proxy: p.Gemini.Proxy,
|
Proxy: p.Gemini.Proxy,
|
||||||
|
RequestTimeout: p.Gemini.RequestTimeout,
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -174,11 +181,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
return ModelConfig{}, false
|
return ModelConfig{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "nvidia",
|
ModelName: "nvidia",
|
||||||
Model: "nvidia/meta/llama-3.1-8b-instruct",
|
Model: "nvidia/meta/llama-3.1-8b-instruct",
|
||||||
APIKey: p.Nvidia.APIKey,
|
APIKey: p.Nvidia.APIKey,
|
||||||
APIBase: p.Nvidia.APIBase,
|
APIBase: p.Nvidia.APIBase,
|
||||||
Proxy: p.Nvidia.Proxy,
|
Proxy: p.Nvidia.Proxy,
|
||||||
|
RequestTimeout: p.Nvidia.RequestTimeout,
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -190,11 +198,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
return ModelConfig{}, false
|
return ModelConfig{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "ollama",
|
ModelName: "ollama",
|
||||||
Model: "ollama/llama3",
|
Model: "ollama/llama3",
|
||||||
APIKey: p.Ollama.APIKey,
|
APIKey: p.Ollama.APIKey,
|
||||||
APIBase: p.Ollama.APIBase,
|
APIBase: p.Ollama.APIBase,
|
||||||
Proxy: p.Ollama.Proxy,
|
Proxy: p.Ollama.Proxy,
|
||||||
|
RequestTimeout: p.Ollama.RequestTimeout,
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -206,11 +215,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
return ModelConfig{}, false
|
return ModelConfig{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "moonshot",
|
ModelName: "moonshot",
|
||||||
Model: "moonshot/kimi",
|
Model: "moonshot/kimi",
|
||||||
APIKey: p.Moonshot.APIKey,
|
APIKey: p.Moonshot.APIKey,
|
||||||
APIBase: p.Moonshot.APIBase,
|
APIBase: p.Moonshot.APIBase,
|
||||||
Proxy: p.Moonshot.Proxy,
|
Proxy: p.Moonshot.Proxy,
|
||||||
|
RequestTimeout: p.Moonshot.RequestTimeout,
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -222,11 +232,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
return ModelConfig{}, false
|
return ModelConfig{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "shengsuanyun",
|
ModelName: "shengsuanyun",
|
||||||
Model: "shengsuanyun/auto",
|
Model: "shengsuanyun/auto",
|
||||||
APIKey: p.ShengSuanYun.APIKey,
|
APIKey: p.ShengSuanYun.APIKey,
|
||||||
APIBase: p.ShengSuanYun.APIBase,
|
APIBase: p.ShengSuanYun.APIBase,
|
||||||
Proxy: p.ShengSuanYun.Proxy,
|
Proxy: p.ShengSuanYun.Proxy,
|
||||||
|
RequestTimeout: p.ShengSuanYun.RequestTimeout,
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -238,11 +249,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
return ModelConfig{}, false
|
return ModelConfig{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "deepseek",
|
ModelName: "deepseek",
|
||||||
Model: "deepseek/deepseek-chat",
|
Model: "deepseek/deepseek-chat",
|
||||||
APIKey: p.DeepSeek.APIKey,
|
APIKey: p.DeepSeek.APIKey,
|
||||||
APIBase: p.DeepSeek.APIBase,
|
APIBase: p.DeepSeek.APIBase,
|
||||||
Proxy: p.DeepSeek.Proxy,
|
Proxy: p.DeepSeek.Proxy,
|
||||||
|
RequestTimeout: p.DeepSeek.RequestTimeout,
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -254,11 +266,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
return ModelConfig{}, false
|
return ModelConfig{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "cerebras",
|
ModelName: "cerebras",
|
||||||
Model: "cerebras/llama-3.3-70b",
|
Model: "cerebras/llama-3.3-70b",
|
||||||
APIKey: p.Cerebras.APIKey,
|
APIKey: p.Cerebras.APIKey,
|
||||||
APIBase: p.Cerebras.APIBase,
|
APIBase: p.Cerebras.APIBase,
|
||||||
Proxy: p.Cerebras.Proxy,
|
Proxy: p.Cerebras.Proxy,
|
||||||
|
RequestTimeout: p.Cerebras.RequestTimeout,
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -270,11 +283,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
return ModelConfig{}, false
|
return ModelConfig{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "volcengine",
|
ModelName: "volcengine",
|
||||||
Model: "volcengine/doubao-pro",
|
Model: "volcengine/doubao-pro",
|
||||||
APIKey: p.VolcEngine.APIKey,
|
APIKey: p.VolcEngine.APIKey,
|
||||||
APIBase: p.VolcEngine.APIBase,
|
APIBase: p.VolcEngine.APIBase,
|
||||||
Proxy: p.VolcEngine.Proxy,
|
Proxy: p.VolcEngine.Proxy,
|
||||||
|
RequestTimeout: p.VolcEngine.RequestTimeout,
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -316,11 +330,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
return ModelConfig{}, false
|
return ModelConfig{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "qwen",
|
ModelName: "qwen",
|
||||||
Model: "qwen/qwen-max",
|
Model: "qwen/qwen-max",
|
||||||
APIKey: p.Qwen.APIKey,
|
APIKey: p.Qwen.APIKey,
|
||||||
APIBase: p.Qwen.APIBase,
|
APIBase: p.Qwen.APIBase,
|
||||||
Proxy: p.Qwen.Proxy,
|
Proxy: p.Qwen.Proxy,
|
||||||
|
RequestTimeout: p.Qwen.RequestTimeout,
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -332,11 +347,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
return ModelConfig{}, false
|
return ModelConfig{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "mistral",
|
ModelName: "mistral",
|
||||||
Model: "mistral/mistral-small-latest",
|
Model: "mistral/mistral-small-latest",
|
||||||
APIKey: p.Mistral.APIKey,
|
APIKey: p.Mistral.APIKey,
|
||||||
APIBase: p.Mistral.APIBase,
|
APIBase: p.Mistral.APIBase,
|
||||||
Proxy: p.Mistral.Proxy,
|
Proxy: p.Mistral.Proxy,
|
||||||
|
RequestTimeout: p.Mistral.RequestTimeout,
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,27 @@ func TestConvertProvidersToModelList_Proxy(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
Ollama: ProviderConfig{
|
||||||
|
APIKey: "ollama-key",
|
||||||
|
RequestTimeout: 300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[0].RequestTimeout != 300 {
|
||||||
|
t.Errorf("RequestTimeout = %d, want %d", result[0].RequestTimeout, 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
|
func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Providers: ProvidersConfig{
|
Providers: ProvidersConfig{
|
||||||
|
|
|
||||||
|
|
@ -365,3 +365,38 @@ func TestConfig_ValidateModelList(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModelConfig_RequestTimeoutParsing(t *testing.T) {
|
||||||
|
jsonData := `{
|
||||||
|
"model_name": "slow-local",
|
||||||
|
"model": "openai/local-model",
|
||||||
|
"api_base": "http://localhost:11434/v1",
|
||||||
|
"request_timeout": 300
|
||||||
|
}`
|
||||||
|
|
||||||
|
var cfg ModelConfig
|
||||||
|
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.RequestTimeout != 300 {
|
||||||
|
t.Fatalf("RequestTimeout = %d, want 300", cfg.RequestTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelConfig_RequestTimeoutDefaultZeroValue(t *testing.T) {
|
||||||
|
jsonData := `{
|
||||||
|
"model_name": "default-timeout",
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"api_key": "test-key"
|
||||||
|
}`
|
||||||
|
|
||||||
|
var cfg ModelConfig
|
||||||
|
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.RequestTimeout != 0 {
|
||||||
|
t.Fatalf("RequestTimeout = %d, want 0", cfg.RequestTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,12 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/adhocore/gronx"
|
"github.com/adhocore/gronx"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CronSchedule struct {
|
type CronSchedule struct {
|
||||||
|
|
@ -330,17 +331,13 @@ func (cs *CronService) loadStore() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CronService) saveStoreUnsafe() error {
|
func (cs *CronService) saveStoreUnsafe() error {
|
||||||
dir := filepath.Dir(cs.storePath)
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.MarshalIndent(cs.store, "", " ")
|
data, err := json.MarshalIndent(cs.store, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(cs.storePath, data, 0o600)
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
return fileutil.WriteFileAtomic(cs.storePath, data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CronService) AddJob(
|
func (cs *CronService) AddJob(
|
||||||
|
|
|
||||||
119
pkg/fileutil/file.go
Normal file
119
pkg/fileutil/file.go
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
// Package fileutil provides file manipulation utilities.
|
||||||
|
package fileutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteFileAtomic atomically writes data to a file using a temp file + rename pattern.
|
||||||
|
//
|
||||||
|
// This guarantees that the target file is either:
|
||||||
|
// - Completely written with the new data
|
||||||
|
// - Unchanged (if any step fails before rename)
|
||||||
|
//
|
||||||
|
// The function:
|
||||||
|
// 1. Creates a temp file in the same directory (original untouched)
|
||||||
|
// 2. Writes data to temp file
|
||||||
|
// 3. Syncs data to disk (critical for SD cards/flash storage)
|
||||||
|
// 4. Sets file permissions
|
||||||
|
// 5. Syncs directory metadata (ensures rename is durable)
|
||||||
|
// 6. Atomically renames temp file to target path
|
||||||
|
//
|
||||||
|
// Safety guarantees:
|
||||||
|
// - Original file is NEVER modified until successful rename
|
||||||
|
// - Temp file is always cleaned up on error
|
||||||
|
// - Data is flushed to physical storage before rename
|
||||||
|
// - Directory entry is synced to prevent orphaned inodes
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - path: Target file path
|
||||||
|
// - data: Data to write
|
||||||
|
// - perm: File permission mode (e.g., 0o600 for secure, 0o644 for readable)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - Error if any step fails, nil on success
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// // Secure config file (owner read/write only)
|
||||||
|
// err := utils.WriteFileAtomic("config.json", data, 0o600)
|
||||||
|
//
|
||||||
|
// // Public readable file
|
||||||
|
// err := utils.WriteFileAtomic("public.txt", data, 0o644)
|
||||||
|
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temp file in the same directory (ensures atomic rename works)
|
||||||
|
// Using a hidden prefix (.tmp-) to avoid issues with some tools
|
||||||
|
tmpFile, err := os.OpenFile(
|
||||||
|
filepath.Join(dir, fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano())),
|
||||||
|
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
||||||
|
perm,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
cleanup := true
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if cleanup {
|
||||||
|
tmpFile.Close()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Write data to temp file
|
||||||
|
// Note: Original file is untouched at this point
|
||||||
|
if _, err := tmpFile.Write(data); err != nil {
|
||||||
|
return fmt.Errorf("failed to write temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Force sync to storage medium before any other operations.
|
||||||
|
// This ensures data is physically written to disk, not just cached.
|
||||||
|
// Essential for SD cards, eMMC, and other flash storage on edge devices.
|
||||||
|
if err := tmpFile.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("failed to sync temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set file permissions before closing
|
||||||
|
if err := tmpFile.Chmod(perm); err != nil {
|
||||||
|
return fmt.Errorf("failed to set permissions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close file before rename (required on Windows)
|
||||||
|
if err := tmpFile.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic rename: temp file becomes the target
|
||||||
|
// On POSIX: rename() is atomic
|
||||||
|
// On Windows: Rename() is atomic for files
|
||||||
|
if err := os.Rename(tmpPath, path); err != nil {
|
||||||
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync directory to ensure rename is durable
|
||||||
|
// This prevents the renamed file from disappearing after a crash
|
||||||
|
if dirFile, err := os.Open(dir); err == nil {
|
||||||
|
_ = dirFile.Sync()
|
||||||
|
dirFile.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: skip cleanup (file was renamed, no temp to remove)
|
||||||
|
cleanup = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/constants"
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
|
@ -275,7 +276,7 @@ This file contains tasks for the heartbeat service to check periodically.
|
||||||
Add your heartbeat tasks below this line:
|
Add your heartbeat tasks below this line:
|
||||||
`
|
`
|
||||||
|
|
||||||
if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
|
if err := fileutil.WriteFileAtomic(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
|
||||||
hs.logErrorf("Failed to create default HEARTBEAT.md: %v", err)
|
hs.logErrorf("Failed to create default HEARTBEAT.md: %v", err)
|
||||||
} else {
|
} else {
|
||||||
hs.logInfof("Created default HEARTBEAT.md template")
|
hs.logInfof("Created default HEARTBEAT.md template")
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
if apiBase == "" {
|
if apiBase == "" {
|
||||||
apiBase = getDefaultAPIBase(protocol)
|
apiBase = getDefaultAPIBase(protocol)
|
||||||
}
|
}
|
||||||
return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
|
cfg.APIKey,
|
||||||
|
apiBase,
|
||||||
|
cfg.Proxy,
|
||||||
|
cfg.MaxTokensField,
|
||||||
|
cfg.RequestTimeout,
|
||||||
|
), modelID, nil
|
||||||
|
|
||||||
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||||
|
|
@ -97,7 +103,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
if apiBase == "" {
|
if apiBase == "" {
|
||||||
apiBase = getDefaultAPIBase(protocol)
|
apiBase = getDefaultAPIBase(protocol)
|
||||||
}
|
}
|
||||||
return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
|
cfg.APIKey,
|
||||||
|
apiBase,
|
||||||
|
cfg.Proxy,
|
||||||
|
cfg.MaxTokensField,
|
||||||
|
cfg.RequestTimeout,
|
||||||
|
), modelID, nil
|
||||||
|
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
|
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
|
||||||
|
|
@ -116,7 +128,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
if cfg.APIKey == "" {
|
if cfg.APIKey == "" {
|
||||||
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
||||||
}
|
}
|
||||||
return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
|
cfg.APIKey,
|
||||||
|
apiBase,
|
||||||
|
cfg.Proxy,
|
||||||
|
cfg.MaxTokensField,
|
||||||
|
cfg.RequestTimeout,
|
||||||
|
), modelID, nil
|
||||||
|
|
||||||
case "antigravity":
|
case "antigravity":
|
||||||
return NewAntigravityProvider(), modelID, nil
|
return NewAntigravityProvider(), modelID, nil
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@
|
||||||
package providers
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
@ -247,3 +251,42 @@ func TestCreateProviderFromConfig_EmptyModel(t *testing.T) {
|
||||||
t.Fatal("CreateProviderFromConfig() expected error for empty model")
|
t.Fatal("CreateProviderFromConfig() expected error for empty model")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(1500 * time.Millisecond)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-timeout",
|
||||||
|
Model: "openai/gpt-4o",
|
||||||
|
APIBase: server.URL,
|
||||||
|
RequestTimeout: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if modelID != "gpt-4o" {
|
||||||
|
t.Fatalf("modelID = %q, want %q", modelID, "gpt-4o")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = provider.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
modelID,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Chat() expected timeout error, got nil")
|
||||||
|
}
|
||||||
|
errMsg := err.Error()
|
||||||
|
if !strings.Contains(errMsg, "context deadline exceeded") && !strings.Contains(errMsg, "Client.Timeout exceeded") {
|
||||||
|
t.Fatalf("Chat() error = %q, want timeout-related error", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/openai_compat"
|
"github.com/sipeed/picoclaw/pkg/providers/openai_compat"
|
||||||
)
|
)
|
||||||
|
|
@ -23,8 +24,21 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
|
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
|
||||||
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
|
apiKey, apiBase, proxy, maxTokensField string,
|
||||||
|
requestTimeoutSeconds int,
|
||||||
|
) *HTTPProvider {
|
||||||
return &HTTPProvider{
|
return &HTTPProvider{
|
||||||
delegate: openai_compat.NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField),
|
delegate: openai_compat.NewProvider(
|
||||||
|
apiKey,
|
||||||
|
apiBase,
|
||||||
|
proxy,
|
||||||
|
openai_compat.WithMaxTokensField(maxTokensField),
|
||||||
|
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,21 @@ import (
|
||||||
func CreateProvider(cfg *config.Config) (LLMProvider, string, error) {
|
func CreateProvider(cfg *config.Config) (LLMProvider, string, error) {
|
||||||
model := cfg.Agents.Defaults.GetModelName()
|
model := cfg.Agents.Defaults.GetModelName()
|
||||||
|
|
||||||
// Ensure model_list is populated (should be done by LoadConfig, but handle edge cases)
|
// Ensure model_list is populated from providers config if needed
|
||||||
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
|
// This handles two cases:
|
||||||
cfg.ModelList = config.ConvertProvidersToModelList(cfg)
|
// 1. ModelList is empty - convert all providers
|
||||||
|
// 2. ModelList has some entries but not all providers - merge missing ones
|
||||||
|
if cfg.HasProvidersConfig() {
|
||||||
|
providerModels := config.ConvertProvidersToModelList(cfg)
|
||||||
|
existingModelNames := make(map[string]bool)
|
||||||
|
for _, m := range cfg.ModelList {
|
||||||
|
existingModelNames[m.ModelName] = true
|
||||||
|
}
|
||||||
|
for _, pm := range providerModels {
|
||||||
|
if !existingModelNames[pm.ModelName] {
|
||||||
|
cfg.ModelList = append(cfg.ModelList, pm)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must have model_list at this point
|
// Must have model_list at this point
|
||||||
|
|
|
||||||
|
|
@ -34,13 +34,27 @@ type Provider struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProvider(apiKey, apiBase, proxy string) *Provider {
|
type Option func(*Provider)
|
||||||
return NewProviderWithMaxTokensField(apiKey, apiBase, proxy, "")
|
|
||||||
|
const defaultRequestTimeout = 120 * time.Second
|
||||||
|
|
||||||
|
func WithMaxTokensField(maxTokensField string) Option {
|
||||||
|
return func(p *Provider) {
|
||||||
|
p.maxTokensField = maxTokensField
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
|
func WithRequestTimeout(timeout time.Duration) Option {
|
||||||
|
return func(p *Provider) {
|
||||||
|
if timeout > 0 {
|
||||||
|
p.httpClient.Timeout = timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 120 * time.Second,
|
Timeout: defaultRequestTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
if proxy != "" {
|
if proxy != "" {
|
||||||
|
|
@ -54,12 +68,36 @@ func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Provider{
|
p := &Provider{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: strings.TrimRight(apiBase, "/"),
|
apiBase: strings.TrimRight(apiBase, "/"),
|
||||||
maxTokensField: maxTokensField,
|
httpClient: client,
|
||||||
httpClient: client,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
if opt != nil {
|
||||||
|
opt(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
|
||||||
|
return NewProvider(apiKey, apiBase, proxy, WithMaxTokensField(maxTokensField))
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProviderWithMaxTokensFieldAndTimeout(
|
||||||
|
apiKey, apiBase, proxy, maxTokensField string,
|
||||||
|
requestTimeoutSeconds int,
|
||||||
|
) *Provider {
|
||||||
|
return NewProvider(
|
||||||
|
apiKey,
|
||||||
|
apiBase,
|
||||||
|
proxy,
|
||||||
|
WithMaxTokensField(maxTokensField),
|
||||||
|
WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Provider) Chat(
|
func (p *Provider) Chat(
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
|
func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
|
||||||
|
|
@ -325,3 +326,38 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) {
|
||||||
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
|
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProvider_RequestTimeoutDefault(t *testing.T) {
|
||||||
|
p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", 0)
|
||||||
|
if p.httpClient.Timeout != defaultRequestTimeout {
|
||||||
|
t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvider_RequestTimeoutOverride(t *testing.T) {
|
||||||
|
p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", 300)
|
||||||
|
if p.httpClient.Timeout != 300*time.Second {
|
||||||
|
t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvider_FunctionalOptionMaxTokensField(t *testing.T) {
|
||||||
|
p := NewProvider("key", "https://example.com/v1", "", WithMaxTokensField("max_completion_tokens"))
|
||||||
|
if p.maxTokensField != "max_completion_tokens" {
|
||||||
|
t.Fatalf("maxTokensField = %q, want %q", p.maxTokensField, "max_completion_tokens")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvider_FunctionalOptionRequestTimeout(t *testing.T) {
|
||||||
|
p := NewProvider("key", "https://example.com/v1", "", WithRequestTimeout(45*time.Second))
|
||||||
|
if p.httpClient.Timeout != 45*time.Second {
|
||||||
|
t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, 45*time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvider_FunctionalOptionRequestTimeoutNonPositive(t *testing.T) {
|
||||||
|
p := NewProvider("key", "https://example.com/v1", "", WithRequestTimeout(-1*time.Second))
|
||||||
|
if p.httpClient.Timeout != defaultRequestTimeout {
|
||||||
|
t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SkillInstaller struct {
|
type SkillInstaller struct {
|
||||||
|
|
@ -44,7 +47,7 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := utils.DoRequestWithRetry(client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to fetch skill: %w", err)
|
return fmt.Errorf("failed to fetch skill: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +67,9 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er
|
||||||
}
|
}
|
||||||
|
|
||||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||||
if err := os.WriteFile(skillPath, body, 0o644); err != nil {
|
|
||||||
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
if err := fileutil.WriteFileAtomic(skillPath, body, 0o600); err != nil {
|
||||||
return fmt.Errorf("failed to write skill file: %w", err)
|
return fmt.Errorf("failed to write skill file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,7 +99,7 @@ func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableS
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := utils.DoRequestWithRetry(client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch skills list: %w", err)
|
return nil, fmt.Errorf("failed to fetch skills list: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,11 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
|
var (
|
||||||
|
namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
|
||||||
|
reFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`)
|
||||||
|
reStripFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MaxNameLength = 64
|
MaxNameLength = 64
|
||||||
|
|
@ -257,10 +261,7 @@ func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
|
||||||
|
|
||||||
func (sl *SkillsLoader) extractFrontmatter(content string) string {
|
func (sl *SkillsLoader) extractFrontmatter(content string) string {
|
||||||
// Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
|
// Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
|
||||||
// (?s) enables DOTALL so . matches newlines;
|
match := reFrontmatter.FindStringSubmatch(content)
|
||||||
// ^--- at start, then ... --- at start of line, honoring all three line ending types
|
|
||||||
re := regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`)
|
|
||||||
match := re.FindStringSubmatch(content)
|
|
||||||
if len(match) > 1 {
|
if len(match) > 1 {
|
||||||
return match[1]
|
return match[1]
|
||||||
}
|
}
|
||||||
|
|
@ -268,12 +269,7 @@ func (sl *SkillsLoader) extractFrontmatter(content string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sl *SkillsLoader) stripFrontmatter(content string) string {
|
func (sl *SkillsLoader) stripFrontmatter(content string) string {
|
||||||
// Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
|
return reStripFrontmatter.ReplaceAllString(content, "")
|
||||||
// (?s) enables DOTALL so . matches newlines;
|
|
||||||
// ^--- at start, then ... --- at start of line, honoring all three line ending types
|
|
||||||
// Match zero or more trailing line endings after closing --- (handles both with and without blank lines)
|
|
||||||
re := regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
|
|
||||||
return re.ReplaceAllString(content, "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func escapeXML(s string) string {
|
func escapeXML(s string) string {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// State represents the persistent state for a workspace.
|
// State represents the persistent state for a workspace.
|
||||||
|
|
@ -124,33 +126,20 @@ func (sm *Manager) GetTimestamp() time.Time {
|
||||||
// saveAtomic performs an atomic save using temp file + rename.
|
// saveAtomic performs an atomic save using temp file + rename.
|
||||||
// This ensures that the state file is never corrupted:
|
// This ensures that the state file is never corrupted:
|
||||||
// 1. Write to a temp file
|
// 1. Write to a temp file
|
||||||
// 2. Rename temp file to target (atomic on POSIX systems)
|
// 2. Sync to disk (critical for SD cards/flash storage)
|
||||||
// 3. If rename fails, cleanup the temp file
|
// 3. Rename temp file to target (atomic on POSIX systems)
|
||||||
|
// 4. If rename fails, cleanup the temp file
|
||||||
//
|
//
|
||||||
// Must be called with the lock held.
|
// Must be called with the lock held.
|
||||||
func (sm *Manager) saveAtomic() error {
|
func (sm *Manager) saveAtomic() error {
|
||||||
// Create temp file in the same directory as the target
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
tempFile := sm.stateFile + ".tmp"
|
// Using 0o600 (owner read/write only) for secure default permissions.
|
||||||
|
|
||||||
// Marshal state to JSON
|
|
||||||
data, err := json.MarshalIndent(sm.state, "", " ")
|
data, err := json.MarshalIndent(sm.state, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal state: %w", err)
|
return fmt.Errorf("failed to marshal state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to temp file
|
return fileutil.WriteFileAtomic(sm.stateFile, data, 0o600)
|
||||||
if err := os.WriteFile(tempFile, data, 0o644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atomic rename from temp to target
|
|
||||||
if err := os.Rename(tempFile, sm.stateFile); err != nil {
|
|
||||||
// Cleanup temp file if rename fails
|
|
||||||
os.Remove(tempFile)
|
|
||||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// load loads the state from disk.
|
// load loads the state from disk.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// validatePath ensures the given path is within the workspace if restrict is true.
|
// validatePath ensures the given path is within the workspace if restrict is true.
|
||||||
|
|
@ -276,25 +278,9 @@ func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *hostFs) WriteFile(path string, data []byte) error {
|
func (h *hostFs) WriteFile(path string, data []byte) error {
|
||||||
dir := filepath.Dir(path)
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
// Using 0o600 (owner read/write only) for secure default permissions.
|
||||||
return fmt.Errorf("failed to create parent directories: %w", err)
|
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||||
}
|
|
||||||
|
|
||||||
// We use a "write-then-rename" pattern here to ensure an atomic write.
|
|
||||||
// This prevents the target file from being left in a truncated or partial state
|
|
||||||
// if the operation is interrupted, as the rename operation is atomic on Linux.
|
|
||||||
tmpPath := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano())
|
|
||||||
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
|
|
||||||
os.Remove(tmpPath) // Ensure cleanup of partial/empty temp file
|
|
||||||
return fmt.Errorf("failed to write temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Rename(tmpPath, path); err != nil {
|
|
||||||
os.Remove(tmpPath)
|
|
||||||
return fmt.Errorf("failed to replace original file: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
|
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
|
||||||
|
|
@ -351,20 +337,46 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// We use a "write-then-rename" pattern here to ensure an atomic write.
|
// Use atomic write pattern with explicit sync for flash storage reliability.
|
||||||
// This prevents the target file from being left in a truncated or partial state
|
// Using 0o600 (owner read/write only) for secure default permissions.
|
||||||
// if the operation is interrupted, as the rename operation is atomic on Linux.
|
tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano())
|
||||||
tmpRelPath := fmt.Sprintf("%s.%d.tmp", relPath, time.Now().UnixNano())
|
|
||||||
|
|
||||||
if err := root.WriteFile(tmpRelPath, data, 0o644); err != nil {
|
tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||||
root.Remove(tmpRelPath) // Ensure cleanup of partial/empty temp file
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write to temp file: %w", err)
|
root.Remove(tmpRelPath)
|
||||||
|
return fmt.Errorf("failed to open temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tmpFile.Write(data); err != nil {
|
||||||
|
tmpFile.Close()
|
||||||
|
root.Remove(tmpRelPath)
|
||||||
|
return fmt.Errorf("failed to write temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Force sync to storage medium before rename.
|
||||||
|
// This ensures data is physically written to disk, not just cached.
|
||||||
|
if err := tmpFile.Sync(); err != nil {
|
||||||
|
tmpFile.Close()
|
||||||
|
root.Remove(tmpRelPath)
|
||||||
|
return fmt.Errorf("failed to sync temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tmpFile.Close(); err != nil {
|
||||||
|
root.Remove(tmpRelPath)
|
||||||
|
return fmt.Errorf("failed to close temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := root.Rename(tmpRelPath, relPath); err != nil {
|
if err := root.Rename(tmpRelPath, relPath); err != nil {
|
||||||
root.Remove(tmpRelPath)
|
root.Remove(tmpRelPath)
|
||||||
return fmt.Errorf("failed to rename temp file over target: %w", err)
|
return fmt.Errorf("failed to rename temp file over target: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync directory to ensure rename is durable
|
||||||
|
if dirFile, err := root.Open("."); err == nil {
|
||||||
|
_ = dirFile.Sync()
|
||||||
|
dirFile.Close()
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
|
@ -197,5 +198,6 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0o644)
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,19 @@ const (
|
||||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Pre-compiled regexes for HTML text extraction
|
||||||
|
var (
|
||||||
|
reScript = regexp.MustCompile(`<script[\s\S]*?</script>`)
|
||||||
|
reStyle = regexp.MustCompile(`<style[\s\S]*?</style>`)
|
||||||
|
reTags = regexp.MustCompile(`<[^>]+>`)
|
||||||
|
reWhitespace = regexp.MustCompile(`[^\S\n]+`)
|
||||||
|
reBlankLines = regexp.MustCompile(`\n{3,}`)
|
||||||
|
|
||||||
|
// DuckDuckGo result extraction
|
||||||
|
reDDGLink = regexp.MustCompile(`<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a>`)
|
||||||
|
reDDGSnippet = regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
|
||||||
|
)
|
||||||
|
|
||||||
// createHTTPClient creates an HTTP client with optional proxy support
|
// createHTTPClient creates an HTTP client with optional proxy support
|
||||||
func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
|
func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
|
|
@ -251,8 +264,7 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query
|
||||||
// Try finding the result links directly first, as they are the most critical
|
// Try finding the result links directly first, as they are the most critical
|
||||||
// Pattern: <a class="result__a" href="...">Title</a>
|
// Pattern: <a class="result__a" href="...">Title</a>
|
||||||
// The previous regex was a bit strict. Let's make it more flexible for attributes order/content
|
// The previous regex was a bit strict. Let's make it more flexible for attributes order/content
|
||||||
reLink := regexp.MustCompile(`<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a>`)
|
matches := reDDGLink.FindAllStringSubmatch(html, count+5)
|
||||||
matches := reLink.FindAllStringSubmatch(html, count+5)
|
|
||||||
|
|
||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
return fmt.Sprintf("No results found or extraction failed. Query: %s", query), nil
|
return fmt.Sprintf("No results found or extraction failed. Query: %s", query), nil
|
||||||
|
|
@ -269,8 +281,7 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query
|
||||||
|
|
||||||
// A better regex approach: iterate through text and find matches in order
|
// A better regex approach: iterate through text and find matches in order
|
||||||
// But for now, let's grab all snippets too
|
// But for now, let's grab all snippets too
|
||||||
reSnippet := regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
|
snippetMatches := reDDGSnippet.FindAllStringSubmatch(html, count+5)
|
||||||
snippetMatches := reSnippet.FindAllStringSubmatch(html, count+5)
|
|
||||||
|
|
||||||
maxItems := min(len(matches), count)
|
maxItems := min(len(matches), count)
|
||||||
|
|
||||||
|
|
@ -305,8 +316,7 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query
|
||||||
}
|
}
|
||||||
|
|
||||||
func stripTags(content string) string {
|
func stripTags(content string) string {
|
||||||
re := regexp.MustCompile(`<[^>]+>`)
|
return reTags.ReplaceAllString(content, "")
|
||||||
return re.ReplaceAllString(content, "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PerplexitySearchProvider struct {
|
type PerplexitySearchProvider struct {
|
||||||
|
|
@ -654,19 +664,14 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *WebFetchTool) extractText(htmlContent string) string {
|
func (t *WebFetchTool) extractText(htmlContent string) string {
|
||||||
re := regexp.MustCompile(`<script[\s\S]*?</script>`)
|
result := reScript.ReplaceAllLiteralString(htmlContent, "")
|
||||||
result := re.ReplaceAllLiteralString(htmlContent, "")
|
result = reStyle.ReplaceAllLiteralString(result, "")
|
||||||
re = regexp.MustCompile(`<style[\s\S]*?</style>`)
|
result = reTags.ReplaceAllLiteralString(result, "")
|
||||||
result = re.ReplaceAllLiteralString(result, "")
|
|
||||||
re = regexp.MustCompile(`<[^>]+>`)
|
|
||||||
result = re.ReplaceAllLiteralString(result, "")
|
|
||||||
|
|
||||||
result = strings.TrimSpace(result)
|
result = strings.TrimSpace(result)
|
||||||
|
|
||||||
re = regexp.MustCompile(`[^\S\n]+`)
|
result = reWhitespace.ReplaceAllString(result, " ")
|
||||||
result = re.ReplaceAllString(result, " ")
|
result = reBlankLines.ReplaceAllString(result, "\n\n")
|
||||||
re = regexp.MustCompile(`\n{3,}`)
|
|
||||||
result = re.ReplaceAllString(result, "\n\n")
|
|
||||||
|
|
||||||
lines := strings.Split(result, "\n")
|
lines := strings.Split(result, "\n")
|
||||||
var cleanLines []string
|
var cleanLines []string
|
||||||
|
|
|
||||||
57
pkg/utils/http_retry.go
Normal file
57
pkg/utils/http_retry.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxRetries = 3
|
||||||
|
|
||||||
|
var retryDelayUnit = time.Second
|
||||||
|
|
||||||
|
func shouldRetry(statusCode int) bool {
|
||||||
|
return statusCode == http.StatusTooManyRequests ||
|
||||||
|
statusCode >= 500
|
||||||
|
}
|
||||||
|
|
||||||
|
func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||||
|
var resp *http.Response
|
||||||
|
var err error
|
||||||
|
|
||||||
|
for i := range maxRetries {
|
||||||
|
if i > 0 && resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !shouldRetry(resp.StatusCode) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if i < maxRetries-1 {
|
||||||
|
if err = sleepWithCtx(req.Context(), retryDelayUnit*time.Duration(i+1)); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to sleep: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func sleepWithCtx(ctx context.Context, d time.Duration) error {
|
||||||
|
timer := time.NewTimer(d)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
118
pkg/utils/http_retry_test.go
Normal file
118
pkg/utils/http_retry_test.go
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDoRequestWithRetry(t *testing.T) {
|
||||||
|
retryDelayUnit = time.Millisecond
|
||||||
|
t.Cleanup(func() { retryDelayUnit = time.Second })
|
||||||
|
|
||||||
|
testcases := []struct {
|
||||||
|
name string
|
||||||
|
serverBehavior func(*httptest.Server) int
|
||||||
|
wantSuccess bool
|
||||||
|
wantAttempts int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "success-on-first-attempt",
|
||||||
|
serverBehavior: func(server *httptest.Server) int {
|
||||||
|
return 0
|
||||||
|
},
|
||||||
|
wantSuccess: true,
|
||||||
|
wantAttempts: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fail-all-attempts",
|
||||||
|
serverBehavior: func(server *httptest.Server) int {
|
||||||
|
return 4
|
||||||
|
},
|
||||||
|
wantSuccess: false,
|
||||||
|
wantAttempts: 3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testcases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts <= tc.serverBehavior(nil) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("success"))
|
||||||
|
}))
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
server.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, server.URL, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := DoRequestWithRetry(client, req)
|
||||||
|
|
||||||
|
if tc.wantSuccess {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
resp.Body.Close()
|
||||||
|
} else {
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tc.wantAttempts, attempts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequestWithRetry_Delay(t *testing.T) {
|
||||||
|
retryDelayUnit = time.Millisecond
|
||||||
|
t.Cleanup(func() { retryDelayUnit = time.Second })
|
||||||
|
|
||||||
|
var start time.Time
|
||||||
|
delays := []time.Duration{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if len(delays) == 0 {
|
||||||
|
delays = append(delays, 0)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(delays) == 1 {
|
||||||
|
start = time.Now()
|
||||||
|
delays = append(delays, 0)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(delays) == 2 {
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
delays = append(delays, elapsed)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("success"))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, server.URL, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := DoRequestWithRetry(client, req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
assert.GreaterOrEqual(t, delays[2], time.Millisecond)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue