Merge branch 'sipeed:main' into main

This commit is contained in:
wly2lcl 2026-03-13 09:16:26 +08:00 committed by GitHub
commit b11f589c12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1572 additions and 891 deletions

View file

@ -9,64 +9,37 @@ permissions:
contents: read contents: read
jobs: jobs:
create-tag: nightly:
name: Create Git Tag name: Nightly Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
outputs: packages: write
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
changelog: ${{ steps.version.outputs.changelog }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Generate and push tag - name: Compute version
id: version id: version
run: | run: |
DATE=$(date -u +%Y%m%d) DATE=$(date -u +%Y%m%d)
SHA=$(git rev-parse --short=8 HEAD) SHA=$(git rev-parse --short=8 HEAD)
BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true) BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true)
if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then
TAG="v0.0.0-nightly.${DATE}.${SHA}" VERSION="v0.0.0-nightly.${DATE}.${SHA}"
else else
TAG="${BASE_VERSION}-nightly.${DATE}.${SHA}" VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}"
fi fi
VERSION=$TAG
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "Tag $TAG already exists, reusing existing tag"
else
git tag -a "$TAG" -m "Nightly build $VERSION"
fi
git push origin "$TAG"
COMPARE_URL="https://github.com/${{ github.repository }}/commits/${TAG}" COMPARE_URL="https://github.com/${{ github.repository }}/commits/main"
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then
COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...${TAG}" COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...main"
fi fi
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
release:
name: GoReleaser Release
needs: create-tag
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout tag
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ needs.create-tag.outputs.tag }}
- name: Setup Go from go.mod - name: Setup Go from go.mod
id: setup-go id: setup-go
@ -95,6 +68,16 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create local tag for GoReleaser
run: git tag "${{ steps.version.outputs.version }}"
- name: Run GoReleaser - name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v6
with: with:
@ -106,6 +89,7 @@ jobs:
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }} GOVERSION: ${{ steps.setup-go.outputs.go-version }}
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
NIGHTLY_BUILD: "true" NIGHTLY_BUILD: "true"
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
@ -113,26 +97,14 @@ jobs:
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
update-rolling:
name: Update Rolling Nightly
needs: [create-tag, release]
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Update nightly release - name: Update nightly release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.create-tag.outputs.tag }} VERSION: ${{ steps.version.outputs.version }}
TITLE: ${{ needs.create-tag.outputs.version }}
run: | run: |
CHANGELOG='${{ needs.create-tag.outputs.changelog }}' CHANGELOG='${{ steps.version.outputs.changelog }}'
NOTES=$(cat <<EOF NOTES=$(cat <<EOF
Nightly build for **${TITLE}** Nightly build for **${VERSION}**
This is an automated build and may be unstable. Use with caution. This is an automated build and may be unstable. Use with caution.
@ -140,65 +112,27 @@ jobs:
EOF EOF
) )
# Download assets from the newly created release if it exists, # Delete existing nightly release and tag
# otherwise fall back to using locally built dist/ artifacts. gh release delete nightly --cleanup-tag -y 2>/dev/null || true
mkdir -p build
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Downloading assets from GitHub release for $TAG..."
gh release download "$TAG" --dir build
else
echo "GitHub release for $TAG not found; falling back to local dist/ artifacts..."
if [ -d "dist" ]; then
cp -R dist/* build/
else
echo "Error: no GitHub release for $TAG and no local dist/ directory found." >&2
exit 1
fi
fi
# Delete existing nightly release and tag to avoid conflicts # Force-update nightly tag to current HEAD
echo "Deleting existing nightly release and tag..." git config user.name "github-actions[bot]"
gh release delete nightly --cleanup-tag -y || true git config user.email "github-actions[bot]@users.noreply.github.com"
git push origin :refs/tags/nightly || true git tag -fa nightly -m "Nightly build ${VERSION}"
git push origin nightly
# Collect release artifacts from goreleaser dist/
ASSETS=()
for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do
[ -f "$f" ] && ASSETS+=("$f")
done
# Create nightly release (prerelease, NOT latest)
gh release create nightly \ gh release create nightly \
--title "Nightly Build" \ --title "Nightly Build" \
--notes "$NOTES" \ --notes "$NOTES" \
--target "${{ github.sha }}" \ --target "${{ github.sha }}" \
--prerelease \ --prerelease \
build/* --latest=false \
"${ASSETS[@]}"
echo "Cleaning up old nightly releases (keeping only the most recent)..."
gh release list --limit 100 --json tagName -q '.[].tagName | select(contains("-nightly."))' | tail -n +2 | while read -r old_tag; do
if [ -n "$old_tag" ] && [ "$old_tag" != "$TAG" ]; then
echo "Deleting old nightly release: $old_tag"
gh release delete "$old_tag" --cleanup-tag -y || true
fi
done
echo "Cleaning up old 'vX.X.X-nightly...' Docker images on GHCR..."
OWNER="${{ github.repository_owner }}"
PACKAGE_NAME="${{ github.event.repository.name }}"
# Check if owner is an organization or user
ORG_TEST=$(gh api -H "Accept: application/vnd.github+json" /orgs/$OWNER 2>/dev/null || true)
if echo "$ORG_TEST" | grep -q '"login"'; then
ACCOUNT_TYPE="orgs"
else
ACCOUNT_TYPE="users"
fi
PACKAGE_URL="/${ACCOUNT_TYPE}/${OWNER}/packages/container/${PACKAGE_NAME}/versions"
OLD_NIGHTLY_VERSIONS=$(gh api --paginate -H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$PACKAGE_URL" \
--jq ". | map(select(any(.metadata.container.tags[]; contains(\"-nightly.\") and (. != \"nightly\") and (. != \"$TAG\")))) | .[].id" 2>/dev/null || true)
for version_id in $OLD_NIGHTLY_VERSIONS; do
if [ -n "$version_id" ]; then
echo "Deleting Docker image version ID: $version_id"
gh api -X DELETE -H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/${ACCOUNT_TYPE}/${OWNER}/packages/container/${PACKAGE_NAME}/versions/$version_id" || true
fi
done

View file

@ -116,9 +116,9 @@ dockers_v2:
- picoclaw - picoclaw
images: images:
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
- '{{ if not (isEnvSet "NIGHTLY_BUILD") }}docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}{{ end }}' - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
tags: tags:
- "{{ .Tag }}" - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}'
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}' - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
platforms: platforms:
- linux/amd64 - linux/amd64
@ -133,9 +133,9 @@ dockers_v2:
- picoclaw-launcher-tui - picoclaw-launcher-tui
images: images:
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
- '{{ if not (isEnvSet "NIGHTLY_BUILD") }}docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}{{ end }}' - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
tags: tags:
- "{{ .Tag }}-launcher" - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}'
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}' - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}'
platforms: platforms:
- linux/amd64 - linux/amd64
@ -215,6 +215,7 @@ changelog:
# lzma: true # lzma: true
release: release:
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
footer: >- footer: >-
--- ---

View file

@ -1,5 +1,5 @@
<div align="center"> <div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512"> <img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1> <h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1>
@ -206,9 +206,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 Démarrage Rapide ### 🚀 Démarrage Rapide
> [!TIP] > [!TIP]
> Configurez votre clé API dans `~/.picoclaw/config.json`. > Configurez votre clé API dans `~/.picoclaw/config.json`. Obtenez des clés API : [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). La recherche web est optionnelle — obtenez gratuitement l'[API Tavily](https://tavily.com) (1000 requêtes gratuites/mois) ou l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois).
> Obtenir des clés API : [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> La recherche web est **optionnelle** — obtenez gratuitement l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois) ou utilisez le repli automatique intégré.
**1. Initialiser** **1. Initialiser**
@ -222,8 +220,13 @@ picoclaw onboard
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key", "api_key": "sk-your-openai-key",
"request_timeout": 300, "request_timeout": 300,
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
@ -231,7 +234,7 @@ picoclaw onboard
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model_name": "gpt4" "model_name": "gpt-5.4"
} }
}, },
"channels": { "channels": {
@ -977,8 +980,9 @@ Cette conception permet également le **support multi-agent** avec une sélectio
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) | | **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obtenir une clé](https://longcat.chat/platform) | | **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obtenir une clé](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -989,8 +993,13 @@ Cette conception permet également le **support multi-agent** avec une sélectio
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key" "api_key": "sk-your-openai-key"
}, },
{ {
@ -1006,7 +1015,7 @@ Cette conception permet également le **support multi-agent** avec une sélectio
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt-5.2" "model": "gpt-5.4"
} }
} }
} }
@ -1017,8 +1026,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio
**OpenAI** **OpenAI**
```json ```json
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..." "api_key": "sk-..."
} }
``` ```
@ -1061,14 +1079,14 @@ Configurez plusieurs points de terminaison pour le même nom de modèle—PicoCl
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1", "api_base": "https://api1.example.com/v1",
"api_key": "sk-key1" "api_key": "sk-key1"
}, },
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1", "api_base": "https://api2.example.com/v1",
"api_key": "sk-key2" "api_key": "sk-key2"
} }
@ -1200,6 +1218,13 @@ Cela se produit lorsqu'une autre instance du bot est en cours d'exécution. Assu
| Service | Offre Gratuite | Cas d'Utilisation | | Service | Offre Gratuite | Cas d'Utilisation |
| ---------------- | -------------------- | ------------------------------------- | | ---------------- | -------------------- | ------------------------------------- |
| **OpenRouter** | 200K tokens/mois | Multiples modèles (Claude, GPT-4, etc.) | | **OpenRouter** | 200K tokens/mois | Multiples modèles (Claude, GPT-4, etc.) |
| **Zhipu** | 200K tokens/mois | Idéal pour les utilisateurs chinois | | **Volcengine CodingPlan** | 9,9¥/premier mois | Idéal pour les utilisateurs chinois, multiples modèles SOTA (Doubao, DeepSeek, etc.) |
| **Zhipu** | 200K tokens/mois | Convient aux utilisateurs chinois |
| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web | | **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web |
| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) | | **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,5 +1,5 @@
<div align="center"> <div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512"> <img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1> <h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
@ -168,9 +168,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 クイックスタート(ネイティブ) ### 🚀 クイックスタート(ネイティブ)
> [!TIP] > [!TIP]
> `~/.picoclaw/config.json` に API キーを設定してください。 > `~/.picoclaw/config.json` に API キーを設定してください。API キーの取得先: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。Web 検索は **任意** です — 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)。
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> Web 検索は **任意** です - 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
**1. 初期化** **1. 初期化**
@ -184,8 +182,13 @@ picoclaw onboard
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key", "api_key": "sk-your-openai-key",
"request_timeout": 300, "request_timeout": 300,
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
@ -193,7 +196,7 @@ picoclaw onboard
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model_name": "gpt4" "model_name": "gpt-5.4"
} }
}, },
"channels": { "channels": {
@ -918,8 +921,9 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) | | **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [キーを取得](https://longcat.chat/platform) | | **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [キーを取得](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ | | **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -930,8 +934,13 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key" "api_key": "sk-your-openai-key"
}, },
{ {
@ -947,7 +956,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt-5.2" "model": "gpt-5.4"
} }
} }
} }
@ -958,8 +967,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
**OpenAI** **OpenAI**
```json ```json
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..." "api_key": "sk-..."
} }
``` ```
@ -1002,14 +1020,14 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1", "api_base": "https://api1.example.com/v1",
"api_key": "sk-key1" "api_key": "sk-key1"
}, },
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1", "api_base": "https://api2.example.com/v1",
"api_key": "sk-key2" "api_key": "sk-key2"
} }
@ -1120,9 +1138,16 @@ Web 検索を有効にするには:
| サービス | 無料枠 | ユースケース | | サービス | 無料枠 | ユースケース |
|---------|--------|------------| |---------|--------|------------|
| **OpenRouter** | 月 200K トークン | 複数モデルClaude, GPT-4 など) | | **OpenRouter** | 月 200K トークン | 複数モデルClaude, GPT-4 など) |
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 | | **Volcengine CodingPlan** | 9.9元/初月 | 中国ユーザーに最適、複数のSOTAモデルDoubao、DeepSeek等 |
| **Zhipu** | 月 200K トークン | 中国ユーザーに適している |
| **Qwen** | 無料枠あり | 通義千問 (Qwen) | | **Qwen** | 無料枠あり | 通義千問 (Qwen) |
| **Brave Search** | 月 2000 クエリ | Web 検索機能 | | **Brave Search** | 月 2000 クエリ | Web 検索機能 |
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 | | **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
| **Groq** | 無料枠あり | 高速推論Llama, Mixtral | | **Groq** | 無料枠あり | 高速推論Llama, Mixtral |
| **Cerebras** | 無料枠あり | 高速推論Llama, Qwen など) | | **Cerebras** | 無料枠あり | 高速推論Llama, Qwen など) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,5 +1,5 @@
<div align="center"> <div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512"> <img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1> <h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
@ -227,9 +227,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 Quick Start ### 🚀 Quick Start
> [!TIP] > [!TIP]
> Set your API key in `~/.picoclaw/config.json`. > Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month).
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month), [SearXNG](https://github.com/searxng/searxng) (free, self-hosted) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
**1. Initialize** **1. Initialize**
@ -244,7 +242,7 @@ picoclaw onboard
"agents": { "agents": {
"defaults": { "defaults": {
"workspace": "~/.picoclaw/workspace", "workspace": "~/.picoclaw/workspace",
"model_name": "gpt4", "model_name": "gpt-5.4",
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20 "max_tool_iterations": 20
@ -252,8 +250,13 @@ picoclaw onboard
}, },
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "your-api-key", "api_key": "your-api-key",
"request_timeout": 300 "request_timeout": 300
}, },
@ -1030,8 +1033,9 @@ This design also enables **multi-agent support** with flexible provider selectio
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) | | **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | | **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | | **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
@ -1043,8 +1047,13 @@ This design also enables **multi-agent support** with flexible provider selectio
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key" "api_key": "sk-your-openai-key"
}, },
{ {
@ -1060,7 +1069,7 @@ This design also enables **multi-agent support** with flexible provider selectio
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt-5.2" "model": "gpt-5.4"
} }
} }
} }
@ -1072,8 +1081,18 @@ This design also enables **multi-agent support** with flexible provider selectio
```json ```json
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..." "api_key": "sk-..."
} }
``` ```
@ -1152,14 +1171,14 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1", "api_base": "https://api1.example.com/v1",
"api_key": "sk-key1" "api_key": "sk-key1"
}, },
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1", "api_base": "https://api2.example.com/v1",
"api_key": "sk-key2" "api_key": "sk-key2"
} }
@ -1499,9 +1518,16 @@ This happens when another instance of the bot is running. Make sure only one `pi
| Service | Free Tier | Use Case | | Service | Free Tier | Use Case |
| ---------------- | ------------------------ | ------------------------------------- | | ---------------- | ------------------------ | ------------------------------------- |
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | | **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
| **Zhipu** | 200K tokens/month | Best for Chinese users | | **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) |
| **Zhipu** | 200K tokens/month | Suitable for Chinese users |
| **Brave Search** | Paid ($5/1000 queries) | Web search functionality | | **Brave Search** | Paid ($5/1000 queries) | Web search functionality |
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) | | **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | | **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | | **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
| **LongCat** | Up to 5M tokens/day | Fast inference (free tier) | | **LongCat** | Up to 5M tokens/day | Fast inference (free tier) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,5 +1,5 @@
<div align="center"> <div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512"> <img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1> <h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>
@ -207,9 +207,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 Início Rápido ### 🚀 Início Rápido
> [!TIP] > [!TIP]
> Configure sua API key em `~/.picoclaw/config.json`. > Configure sua API key em `~/.picoclaw/config.json`. Obtenha API keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Busca web é **opcional** — obtenha a [API Tavily](https://tavily.com) gratuita (1000 consultas grátis/mês) ou a [Brave Search API](https://brave.com/search/api) (2000 consultas grátis/mês).
> Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas grátis/mês) ou use o fallback automático integrado.
**1. Inicializar** **1. Inicializar**
@ -223,8 +221,13 @@ picoclaw onboard
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key", "api_key": "sk-your-openai-key",
"request_timeout": 300, "request_timeout": 300,
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
@ -232,7 +235,7 @@ picoclaw onboard
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model_name": "gpt4" "model_name": "gpt-5.4"
} }
}, },
"tools": { "tools": {
@ -973,8 +976,9 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) | | **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obter Chave](https://longcat.chat/platform) | | **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obter Chave](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth | | **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -985,8 +989,13 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key" "api_key": "sk-your-openai-key"
}, },
{ {
@ -1002,7 +1011,7 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt-5.2" "model": "gpt-5.4"
} }
} }
} }
@ -1013,8 +1022,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
**OpenAI** **OpenAI**
```json ```json
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..." "api_key": "sk-..."
} }
``` ```
@ -1057,14 +1075,14 @@ Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-r
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1", "api_base": "https://api1.example.com/v1",
"api_key": "sk-key1" "api_key": "sk-key1"
}, },
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1", "api_base": "https://api2.example.com/v1",
"api_key": "sk-key2" "api_key": "sk-key2"
} }
@ -1196,7 +1214,14 @@ Isso acontece quando outra instância do bot está em execução. Certifique-se
| Serviço | Plano Gratuito | Caso de Uso | | Serviço | Plano Gratuito | Caso de Uso |
| --- | --- | --- | | --- | --- | --- |
| **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) | | **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) |
| **Zhipu** | 200K tokens/mês | Melhor para usuários chineses | | **Volcengine CodingPlan** | ¥9,9/primeiro mês | Ideal para usuários chineses, múltiplos modelos SOTA (Doubao, DeepSeek, etc.) |
| **Zhipu** | 200K tokens/mês | Adequado para usuários chineses |
| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web | | **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web |
| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) | | **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) |
| **Cerebras** | Plano gratuito disponível | Inferência ultra-rápida (Llama 3.3 70B) | | **Cerebras** | Plano gratuito disponível | Inferência ultra-rápida (Llama 3.3 70B) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,5 +1,5 @@
<div align="center"> <div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512"> <img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1> <h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
@ -187,9 +187,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 Bắt đầu nhanh ### 🚀 Bắt đầu nhanh
> [!TIP] > [!TIP]
> Thiết lập API key trong `~/.picoclaw/config.json`. > Thiết lập API key trong `~/.picoclaw/config.json`. Lấy API key: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Tìm kiếm web là **tùy chọn** — lấy [Tavily API](https://tavily.com) miễn phí (1000 truy vấn/tháng) hoặc [Brave Search API](https://brave.com/search/api) (2000 truy vấn/tháng).
> Lấy API key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> Tìm kiếm web là **tùy chọn** — lấy [Brave Search API](https://brave.com/search/api) miễn phí (2000 truy vấn/tháng) hoặc dùng tính năng auto fallback tích hợp sẵn.
**1. Khởi tạo** **1. Khởi tạo**
@ -203,8 +201,13 @@ picoclaw onboard
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key", "api_key": "sk-your-openai-key",
"request_timeout": 300, "request_timeout": 300,
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
@ -942,8 +945,9 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) | | **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Lấy Key](https://longcat.chat/platform) | | **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Lấy Key](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth | | **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -954,8 +958,13 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key" "api_key": "sk-your-openai-key"
}, },
{ {
@ -971,7 +980,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt-5.2" "model": "gpt-5.4"
} }
} }
} }
@ -982,8 +991,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
**OpenAI** **OpenAI**
```json ```json
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..." "api_key": "sk-..."
} }
``` ```
@ -1026,14 +1044,14 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1", "api_base": "https://api1.example.com/v1",
"api_key": "sk-key1" "api_key": "sk-key1"
}, },
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1", "api_base": "https://api2.example.com/v1",
"api_key": "sk-key2" "api_key": "sk-key2"
} }
@ -1165,6 +1183,13 @@ Một số nhà cung cấp (như Zhipu) có bộ lọc nội dung nghiêm ngặt
| Dịch vụ | Gói miễn phí | Trường hợp sử dụng | | Dịch vụ | Gói miễn phí | Trường hợp sử dụng |
| --- | --- | --- | | --- | --- | --- |
| **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) | | **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) |
| **Zhipu** | 200K tokens/tháng | Tốt nhất cho người dùng Trung Quốc | | **Volcengine CodingPlan** | ¥9.9/tháng đầu | Tốt nhất cho người dùng Trung Quốc, nhiều mô hình SOTA (Doubao, DeepSeek, v.v.) |
| **Zhipu** | 200K tokens/tháng | Phù hợp cho người dùng Trung Quốc |
| **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web | | **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web |
| **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) | | **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,5 +1,5 @@
<div align="center"> <div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512"> <img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1> <h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
@ -208,9 +208,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 快速开始 ### 🚀 快速开始
> [!TIP] > [!TIP]
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。 > 在 `~/.picoclaw/config.json` 中设置您的 API Key。获取 API Key: [火山引擎 (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。网络搜索是 **可选的** — 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)。
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
**1. 初始化 (Initialize)** **1. 初始化 (Initialize)**
@ -226,7 +224,7 @@ picoclaw onboard
"agents": { "agents": {
"defaults": { "defaults": {
"workspace": "~/.picoclaw/workspace", "workspace": "~/.picoclaw/workspace",
"model_name": "gpt4", "model_name": "gpt-5.4",
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20 "max_tool_iterations": 20
@ -234,8 +232,13 @@ picoclaw onboard
}, },
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "your-api-key", "api_key": "your-api-key",
"request_timeout": 300 "request_timeout": 300
}, },
@ -514,8 +517,9 @@ Agent 读取 HEARTBEAT.md
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) | | **火山引擎Doubao** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) | | **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth | | **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -526,8 +530,13 @@ Agent 读取 HEARTBEAT.md
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key" "api_key": "sk-your-openai-key"
}, },
{ {
@ -543,7 +552,7 @@ Agent 读取 HEARTBEAT.md
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt-5.2" "model": "gpt-5.4"
} }
} }
} }
@ -555,8 +564,18 @@ Agent 读取 HEARTBEAT.md
```json ```json
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**火山引擎Doubao**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..." "api_key": "sk-..."
} }
``` ```
@ -622,14 +641,14 @@ Agent 读取 HEARTBEAT.md
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1", "api_base": "https://api1.example.com/v1",
"api_key": "sk-key1" "api_key": "sk-key1"
}, },
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1", "api_base": "https://api2.example.com/v1",
"api_key": "sk-key2" "api_key": "sk-key2"
} }
@ -875,8 +894,15 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
| 服务 | 免费层级 | 适用场景 | | 服务 | 免费层级 | 适用场景 |
| --- | --- | --- | | --- | --- | --- |
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) | | **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 | | **火山引擎 CodingPlan** | 9.9 元/首月 | 最适合国内用户,多种 SOTA 模型豆包、DeepSeek 等) |
| **智谱 (Zhipu)** | 200K tokens/月 | 适合中国用户 |
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 | | **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 | | **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) | | **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) | | **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

BIN
assets/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -49,7 +49,7 @@ func (s *appState) modelMenu() tview.Primitive {
Action: func() { Action: func() {
newName := s.nextAvailableModelName("new-model") newName := s.nextAvailableModelName("new-model")
s.addModel( s.addModel(
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"}, picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
) )
s.push( s.push(
fmt.Sprintf("model-%d", len(s.config.ModelList)-1), fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
@ -291,7 +291,7 @@ func refreshModelMenuFromState(menu *Menu, s *appState) {
Action: func() { Action: func() {
newName := s.nextAvailableModelName("new-model") newName := s.nextAvailableModelName("new-model")
s.addModel( s.addModel(
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"}, picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
) )
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1)) s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
}, },

View file

@ -72,14 +72,14 @@ func authLoginOpenAI(useDeviceCode bool) error {
// If no openai in ModelList, add it // If no openai in ModelList, add it
if !foundOpenAI { if !foundOpenAI {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
ModelName: "gpt-5.2", ModelName: "gpt-5.4",
Model: "openai/gpt-5.2", Model: "openai/gpt-5.4",
AuthMethod: "oauth", AuthMethod: "oauth",
}) })
} }
// Update default model to use OpenAI // Update default model to use OpenAI
appCfg.Agents.Defaults.ModelName = "gpt-5.2" appCfg.Agents.Defaults.ModelName = "gpt-5.4"
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil { if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
return fmt.Errorf("could not update config: %w", err) return fmt.Errorf("could not update config: %w", err)
@ -90,7 +90,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
if cred.AccountID != "" { if cred.AccountID != "" {
fmt.Printf("Account: %s\n", cred.AccountID) fmt.Printf("Account: %s\n", cred.AccountID)
} }
fmt.Println("Default model set to: gpt-5.2") fmt.Println("Default model set to: gpt-5.4")
return nil return nil
} }
@ -318,13 +318,13 @@ func authLoginPasteToken(provider string) error {
} }
if !found { if !found {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
ModelName: "gpt-5.2", ModelName: "gpt-5.4",
Model: "openai/gpt-5.2", Model: "openai/gpt-5.4",
AuthMethod: "token", AuthMethod: "token",
}) })
} }
// Update default model // Update default model
appCfg.Agents.Defaults.ModelName = "gpt-5.2" appCfg.Agents.Defaults.ModelName = "gpt-5.4"
} }
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil { if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
return fmt.Errorf("could not update config: %w", err) return fmt.Errorf("could not update config: %w", err)

View file

@ -3,7 +3,7 @@
"defaults": { "defaults": {
"workspace": "~/.picoclaw/workspace", "workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true, "restrict_to_workspace": true,
"model_name": "gpt4", "model_name": "gpt-5.4",
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20, "max_tool_iterations": 20,
@ -13,8 +13,8 @@
}, },
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key", "api_key": "sk-your-openai-key",
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
}, },
@ -41,14 +41,14 @@
"api_key": "your-longcat-api-key" "api_key": "your-longcat-api-key"
}, },
{ {
"model_name": "loadbalanced-gpt4", "model_name": "loadbalanced-gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-key1", "api_key": "sk-key1",
"api_base": "https://api1.example.com/v1" "api_base": "https://api1.example.com/v1"
}, },
{ {
"model_name": "loadbalanced-gpt4", "model_name": "loadbalanced-gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-key2", "api_key": "sk-key2",
"api_base": "https://api2.example.com/v1" "api_base": "https://api2.example.com/v1"
} }

View file

@ -66,7 +66,7 @@ Problem: Agent needs to know both `provider` and `model`, adding complexity.
Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design: Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
1. **Model-centric**: Users care about models, not providers 1. **Model-centric**: Users care about models, not providers
2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.2`, `anthropic/claude-sonnet-4.6` 2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.4`, `anthropic/claude-sonnet-4.6`
3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes 3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes
### 2.2 New Configuration Structure ### 2.2 New Configuration Structure
@ -81,8 +81,8 @@ Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
"api_key": "sk-xxx" "api_key": "sk-xxx"
}, },
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-xxx" "api_key": "sk-xxx"
}, },
{ {
@ -128,7 +128,7 @@ type Config struct {
type ModelConfig struct { type ModelConfig struct {
// Required // Required
ModelName string `json:"model_name"` // user-facing name (alias) ModelName string `json:"model_name"` // user-facing name (alias)
Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.2 Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.4
// Common config // Common config
APIBase string `json:"api_base,omitempty"` APIBase string `json:"api_base,omitempty"`
@ -180,7 +180,7 @@ Identify protocol via prefix in `model` field:
"model": "deepseek-chat" "model": "deepseek-chat"
}, },
"coder": { "coder": {
"model": "gpt-5.2", "model": "gpt-5.4",
"system_prompt": "You are a coding assistant..." "system_prompt": "You are a coding assistant..."
}, },
"translator": { "translator": {
@ -200,7 +200,7 @@ Each Agent only needs to specify `model` (corresponds to `model_name` in `model_
model_list: model_list:
- model_name: gpt-4o - model_name: gpt-4o
litellm_params: litellm_params:
model: openai/gpt-5.2 model: openai/gpt-5.4
api_key: xxx api_key: xxx
- model_name: my-custom - model_name: my-custom
litellm_params: litellm_params:

View file

@ -40,7 +40,7 @@ The new `model_list` configuration offers several advantages:
"agents": { "agents": {
"defaults": { "defaults": {
"provider": "openai", "provider": "openai",
"model": "gpt-5.2" "model": "gpt-5.4"
} }
} }
} }
@ -53,7 +53,7 @@ The new `model_list` configuration offers several advantages:
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key", "api_key": "sk-your-openai-key",
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
}, },
@ -82,7 +82,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
| Prefix | Description | Example | | Prefix | Description | Example |
|--------|-------------|---------| |--------|-------------|---------|
| `openai/` | OpenAI API (default) | `openai/gpt-5.2` | | `openai/` | OpenAI API (default) | `openai/gpt-5.4` |
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` | | `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` | | `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` | | `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |
@ -109,7 +109,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
| Field | Required | Description | | Field | Required | Description |
|-------|----------|-------------| |-------|----------|-------------|
| `model_name` | Yes | User-facing alias for the model | | `model_name` | Yes | User-facing alias for the model |
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.2`) | | `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.4`) |
| `api_base` | No | API endpoint URL | | `api_base` | No | API endpoint URL |
| `api_key` | No* | API authentication key | | `api_key` | No* | API authentication key |
| `proxy` | No | HTTP proxy URL | | `proxy` | No | HTTP proxy URL |
@ -130,19 +130,19 @@ Configure multiple endpoints for the same model to distribute load:
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-key1", "api_key": "sk-key1",
"api_base": "https://api1.example.com/v1" "api_base": "https://api1.example.com/v1"
}, },
{ {
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-key2", "api_key": "sk-key2",
"api_base": "https://api2.example.com/v1" "api_base": "https://api2.example.com/v1"
}, },
{ {
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-key3", "api_key": "sk-key3",
"api_base": "https://api3.example.com/v1" "api_base": "https://api3.example.com/v1"
} }

View file

@ -32,6 +32,10 @@ const (
lineBotInfoEndpoint = lineAPIBase + "/info" lineBotInfoEndpoint = lineAPIBase + "/info"
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start" lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
lineReplyTokenMaxAge = 25 * time.Second lineReplyTokenMaxAge = 25 * time.Second
// Limit request body to prevent memory exhaustion (DoS).
// LINE webhook payloads are typically a few KB; 1 MiB is generous.
maxWebhookBodySize = 1 << 20 // 1 MiB
) )
type replyTokenEntry struct { type replyTokenEntry struct {
@ -166,7 +170,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
if err != nil { if err != nil {
logger.ErrorCF("line", "Failed to read request body", map[string]any{ logger.ErrorCF("line", "Failed to read request body", map[string]any{
"error": err.Error(), "error": err.Error(),
@ -174,6 +178,11 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Bad request", http.StatusBadRequest) http.Error(w, "Bad request", http.StatusBadRequest)
return return
} }
if int64(len(body)) > maxWebhookBodySize {
logger.WarnC("line", "Webhook request body too large, rejected")
http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
return
}
signature := r.Header.Get("X-Line-Signature") signature := r.Header.Get("X-Line-Signature")
if !c.verifySignature(body, signature) { if !c.verifySignature(body, signature) {

View file

@ -0,0 +1,81 @@
package line
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestWebhookRejectsOversizedBody(t *testing.T) {
ch := &LINEChannel{}
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
rec := httptest.NewRecorder()
ch.webhookHandler(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code)
}
}
func TestWebhookAcceptsMaxBodySize(t *testing.T) {
ch := &LINEChannel{}
body := bytes.Repeat([]byte("A"), maxWebhookBodySize)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
rec := httptest.NewRecorder()
ch.webhookHandler(rec, req)
// Missing signature should be rejected, but the body size should not trigger 413.
if rec.Code != http.StatusForbidden {
t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code)
}
}
func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) {
ch := &LINEChannel{}
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
req.Header.Set("X-Line-Signature", "invalidsignature")
rec := httptest.NewRecorder()
ch.webhookHandler(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code)
}
}
func TestWebhookRejectsNonPostMethod(t *testing.T) {
ch := &LINEChannel{}
req := httptest.NewRequest(http.MethodGet, "/webhook", nil)
rec := httptest.NewRecorder()
ch.webhookHandler(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestWebhookRejectsInvalidSignature(t *testing.T) {
ch := &LINEChannel{}
body := `{"events":[]}`
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body))
req.Header.Set("X-Line-Signature", "invalidsignature")
rec := httptest.NewRecorder()
ch.webhookHandler(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code)
}
}

View file

@ -127,7 +127,12 @@ func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) b
// Implements PlaceholderRecorder. // Implements PlaceholderRecorder.
func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) { func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
key := channel + ":" + chatID key := channel + ":" + chatID
m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()}) entry := typingEntry{stop: stop, createdAt: time.Now()}
if previous, loaded := m.typingStops.Swap(key, entry); loaded {
if oldEntry, ok := previous.(typingEntry); ok && oldEntry.stop != nil {
oldEntry.stop()
}
}
} }
// RecordReactionUndo registers a reaction undo function for later invocation. // RecordReactionUndo registers a reaction undo function for later invocation.

View file

@ -616,6 +616,37 @@ func TestRecordTypingStop_ConcurrentSafe(t *testing.T) {
wg.Wait() wg.Wait()
} }
func TestRecordTypingStop_ReplacesExistingStop(t *testing.T) {
m := newTestManager()
var oldStopCalls int
var newStopCalls int
m.RecordTypingStop("test", "123", func() {
oldStopCalls++
})
m.RecordTypingStop("test", "123", func() {
newStopCalls++
})
if oldStopCalls != 1 {
t.Fatalf("expected previous typing stop to be called once when replaced, got %d", oldStopCalls)
}
if newStopCalls != 0 {
t.Fatalf("expected replacement typing stop to stay active until preSend, got %d calls", newStopCalls)
}
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
m.preSend(context.Background(), "test", msg, &mockChannel{})
if newStopCalls != 1 {
t.Fatalf("expected replacement typing stop to be called by preSend, got %d", newStopCalls)
}
if oldStopCalls != 1 {
t.Fatalf("expected previous typing stop to not be called again, got %d", oldStopCalls)
}
}
func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) { func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) {
m := newTestManager() m := newTestManager()
var sendCalled bool var sendCalled bool

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"html" "html"
"io"
"mime" "mime"
"net/url" "net/url"
"os" "os"
@ -726,17 +727,23 @@ func (c *MatrixChannel) downloadMedia(
reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second) reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second)
defer cancel() defer cancel()
data, err := c.client.DownloadBytes(reqCtx, parsed) resp, err := c.client.Download(reqCtx, parsed)
if err != nil { if err != nil {
return "", err return "", err
} }
defer resp.Body.Close()
reader := resp.Body
readerClose := func() error { return nil }
// Encrypted attachments put URL in msgEvt.File and require client-side decryption. // Encrypted attachments put URL in msgEvt.File and require client-side decryption.
if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" { if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" {
err = msgEvt.File.DecryptInPlace(data) if err = msgEvt.File.PrepareForDecryption(); err != nil {
if err != nil {
return "", fmt.Errorf("decrypt matrix media: %w", err) return "", fmt.Errorf("decrypt matrix media: %w", err)
} }
decryptReader := msgEvt.File.DecryptStream(resp.Body)
reader = decryptReader
readerClose = decryptReader.Close
} }
label := matrixMediaLabel(msgEvt, mediaKind) label := matrixMediaLabel(msgEvt, mediaKind)
@ -749,14 +756,28 @@ func (c *MatrixChannel) downloadMedia(
if err != nil { if err != nil {
return "", err return "", err
} }
defer tmp.Close() tmpPath := tmp.Name()
cleanup := true
defer func() {
_ = tmp.Close()
if cleanup {
_ = os.Remove(tmpPath)
}
}()
if _, err = tmp.Write(data); err != nil { _, err = io.Copy(tmp, reader)
_ = os.Remove(tmp.Name()) if err != nil {
return "", err
}
if err = readerClose(); err != nil {
return "", fmt.Errorf("decrypt matrix media: %w", err)
}
if err = tmp.Close(); err != nil {
return "", err return "", err
} }
return tmp.Name(), nil cleanup = false
return tmpPath, nil
} }
func matrixContentType(msgEvt *event.MessageEventContent) string { func matrixContentType(msgEvt *event.MessageEventContent) string {

View file

@ -2,6 +2,8 @@ package matrix
import ( import (
"context" "context"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -197,6 +199,50 @@ func TestMatrixMediaExt(t *testing.T) {
} }
} }
func TestDownloadMedia_WritesResponseToTempFile(t *testing.T) {
const wantBody = "matrix-media-payload"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/_matrix/client/v1/media/download/matrix.test/abc123") {
t.Fatalf("unexpected download path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte(wantBody))
}))
defer server.Close()
client, err := mautrix.NewClient(server.URL, id.UserID("@picoclaw:matrix.test"), "")
if err != nil {
t.Fatalf("NewClient: %v", err)
}
ch := &MatrixChannel{client: client}
msg := &event.MessageEventContent{
MsgType: event.MsgImage,
Body: "image.png",
URL: id.ContentURIString("mxc://matrix.test/abc123"),
Info: &event.FileInfo{MimeType: "image/png"},
}
path, err := ch.downloadMedia(context.Background(), msg, "image")
if err != nil {
t.Fatalf("downloadMedia: %v", err)
}
defer os.Remove(path)
if ext := filepath.Ext(path); ext != ".png" {
t.Fatalf("temp file extension=%q want=.png", ext)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(got) != wantBody {
t.Fatalf("file contents=%q want=%q", string(got), wantBody)
}
}
func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) { func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) {
ch := &MatrixChannel{} ch := &MatrixChannel{}
msg := &event.MessageEventContent{ msg := &event.MessageEventContent{

View file

@ -444,7 +444,7 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) {
configPath := filepath.Join(tmpDir, "config.json") configPath := filepath.Join(tmpDir, "config.json")
configJSON := `{ configJSON := `{
"agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}}, "agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}},
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}], "model_list": [{"model_name":"gpt4","model":"openai/gpt-5.4","api_key":"x"}],
"tools": {"web":{"proxy":"http://127.0.0.1:7890"}} "tools": {"web":{"proxy":"http://127.0.0.1:7890"}}
}` }`
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {

View file

@ -194,8 +194,8 @@ func DefaultConfig() *Config {
// OpenAI - https://platform.openai.com/api-keys // OpenAI - https://platform.openai.com/api-keys
{ {
ModelName: "gpt-5.2", ModelName: "gpt-5.4",
Model: "openai/gpt-5.2", Model: "openai/gpt-5.4",
APIBase: "https://api.openai.com/v1", APIBase: "https://api.openai.com/v1",
APIKey: "", APIKey: "",
}, },
@ -256,8 +256,8 @@ func DefaultConfig() *Config {
APIKey: "", APIKey: "",
}, },
{ {
ModelName: "openrouter-gpt-5.2", ModelName: "openrouter-gpt-5.4",
Model: "openrouter/openai/gpt-5.2", Model: "openrouter/openai/gpt-5.4",
APIBase: "https://openrouter.ai/api/v1", APIBase: "https://openrouter.ai/api/v1",
APIKey: "", APIKey: "",
}, },
@ -287,6 +287,12 @@ func DefaultConfig() *Config {
}, },
// Volcengine (火山引擎) - https://console.volcengine.com/ark // Volcengine (火山引擎) - https://console.volcengine.com/ark
{
ModelName: "ark-code-latest",
Model: "volcengine/ark-code-latest",
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
APIKey: "",
},
{ {
ModelName: "doubao-pro", ModelName: "doubao-pro",
Model: "volcengine/doubao-pro-32k", Model: "volcengine/doubao-pro-32k",
@ -311,8 +317,8 @@ func DefaultConfig() *Config {
// GitHub Copilot - https://github.com/settings/tokens // GitHub Copilot - https://github.com/settings/tokens
{ {
ModelName: "copilot-gpt-5.2", ModelName: "copilot-gpt-5.4",
Model: "github-copilot/gpt-5.2", Model: "github-copilot/gpt-5.4",
APIBase: "http://localhost:4321", APIBase: "http://localhost:4321",
AuthMethod: "oauth", AuthMethod: "oauth",
}, },

View file

@ -61,7 +61,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
} }
return ModelConfig{ return ModelConfig{
ModelName: "openai", ModelName: "openai",
Model: "openai/gpt-5.2", Model: "openai/gpt-5.4",
APIKey: p.OpenAI.APIKey, APIKey: p.OpenAI.APIKey,
APIBase: p.OpenAI.APIBase, APIBase: p.OpenAI.APIBase,
Proxy: p.OpenAI.Proxy, Proxy: p.OpenAI.Proxy,
@ -335,7 +335,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
} }
return ModelConfig{ return ModelConfig{
ModelName: "github-copilot", ModelName: "github-copilot",
Model: "github-copilot/gpt-5.2", Model: "github-copilot/gpt-5.4",
APIBase: p.GitHubCopilot.APIBase, APIBase: p.GitHubCopilot.APIBase,
ConnectMode: p.GitHubCopilot.ConnectMode, ConnectMode: p.GitHubCopilot.ConnectMode,
}, true }, true

View file

@ -31,8 +31,8 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
if result[0].ModelName != "openai" { if result[0].ModelName != "openai" {
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai") t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai")
} }
if result[0].Model != "openai/gpt-5.2" { if result[0].Model != "openai/gpt-5.4" {
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.2") t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.4")
} }
if result[0].APIKey != "sk-test-key" { if result[0].APIKey != "sk-test-key" {
t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key") t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key")
@ -384,8 +384,8 @@ func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *tes
for _, mc := range result { for _, mc := range result {
switch mc.ModelName { switch mc.ModelName {
case "openai": case "openai":
if mc.Model != "openai/gpt-5.2" { if mc.Model != "openai/gpt-5.4" {
t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.2") t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.4")
} }
case "deepseek": case "deepseek":
if mc.Model != "deepseek/deepseek-reasoner" { if mc.Model != "deepseek/deepseek-reasoner" {
@ -558,9 +558,9 @@ func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
// Tests for buildModelWithProtocol helper function // Tests for buildModelWithProtocol helper function
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) { func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
result := buildModelWithProtocol("openai", "gpt-5.2") result := buildModelWithProtocol("openai", "gpt-5.4")
if result != "openai/gpt-5.2" { if result != "openai/gpt-5.4" {
t.Errorf("buildModelWithProtocol(openai, gpt-5.2) = %q, want %q", result, "openai/gpt-5.2") t.Errorf("buildModelWithProtocol(openai, gpt-5.4) = %q, want %q", result, "openai/gpt-5.4")
} }
} }

View file

@ -490,7 +490,7 @@ echo '{"type":"turn.completed"}'`
} }
messages := []Message{{Role: "user", Content: "test"}} messages := []Message{{Role: "user", Content: "test"}}
_, err := p.Chat(context.Background(), messages, nil, "gpt-5.2-codex", nil) _, err := p.Chat(context.Background(), messages, nil, "gpt-5.3-codex", nil)
if err != nil { if err != nil {
t.Fatalf("Chat() error: %v", err) t.Fatalf("Chat() error: %v", err)
} }
@ -502,7 +502,7 @@ echo '{"type":"turn.completed"}'`
} }
args := string(argsData) args := string(argsData)
if !strings.Contains(args, "-m gpt-5.2-codex") { if !strings.Contains(args, "-m gpt-5.3-codex") {
t.Errorf("args should contain model flag, got: %s", args) t.Errorf("args should contain model flag, got: %s", args)
} }
if !strings.Contains(args, "-C /tmp/test-workspace") { if !strings.Contains(args, "-C /tmp/test-workspace") {

View file

@ -16,7 +16,7 @@ import (
) )
const ( const (
codexDefaultModel = "gpt-5.2" codexDefaultModel = "gpt-5.3-codex"
codexDefaultInstructions = "You are Codex, a coding assistant." codexDefaultInstructions = "You are Codex, a coding assistant."
) )

View file

@ -568,7 +568,7 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T)
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
messages := []Message{{Role: "user", Content: "Hello"}} messages := []Message{{Role: "user", Content: "Hello"}}
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.2", nil) resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.3-codex", nil)
if err != nil { if err != nil {
t.Fatalf("Chat() error: %v", err) t.Fatalf("Chat() error: %v", err)
} }
@ -599,7 +599,7 @@ func TestResolveCodexModel(t *testing.T) {
wantFallback: true, wantFallback: true,
}, },
{name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true}, {name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true},
{name: "openai prefix", input: "openai/gpt-5.2", wantModel: "gpt-5.2", wantFallback: false}, {name: "openai prefix", input: "openai/gpt-5.3-codex", wantModel: "gpt-5.3-codex", wantFallback: false},
{name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false}, {name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false},
} }

View file

@ -247,7 +247,7 @@ func TestGatewayStartReady_OAuthModelRequiresStoredCredential(t *testing.T) {
} }
cfg.ModelList = []config.ModelConfig{{ cfg.ModelList = []config.ModelConfig{{
ModelName: "openai-oauth", ModelName: "openai-oauth",
Model: "openai/gpt-5.2", Model: "openai/gpt-5.4",
AuthMethod: "oauth", AuthMethod: "oauth",
}} }}
cfg.Agents.Defaults.ModelName = "openai-oauth" cfg.Agents.Defaults.ModelName = "openai-oauth"

View file

@ -62,7 +62,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
cfg.ModelList = []config.ModelConfig{ cfg.ModelList = []config.ModelConfig{
{ {
ModelName: "openai-oauth", ModelName: "openai-oauth",
Model: "openai/gpt-5.2", Model: "openai/gpt-5.4",
AuthMethod: "oauth", AuthMethod: "oauth",
}, },
{ {
@ -81,8 +81,8 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
APIKey: "remote-key", APIKey: "remote-key",
}, },
{ {
ModelName: "copilot-gpt-5.2", ModelName: "copilot-gpt-5.4",
Model: "github-copilot/gpt-5.2", Model: "github-copilot/gpt-5.4",
APIBase: "http://127.0.0.1:4321", APIBase: "http://127.0.0.1:4321",
AuthMethod: "oauth", AuthMethod: "oauth",
}, },
@ -128,7 +128,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
if !got["vllm-remote"] { if !got["vllm-remote"] {
t.Fatalf("remote vllm model configured = false, want true with api_key") t.Fatalf("remote vllm model configured = false, want true with api_key")
} }
if !got["copilot-gpt-5.2"] { if !got["copilot-gpt-5.4"] {
t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds") t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds")
} }
if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model" { if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model" {

View file

@ -791,8 +791,8 @@ func defaultModelConfigForProvider(provider, authMethod string) config.ModelConf
switch provider { switch provider {
case oauthProviderOpenAI: case oauthProviderOpenAI:
return config.ModelConfig{ return config.ModelConfig{
ModelName: "gpt-5.2", ModelName: "gpt-5.4",
Model: "openai/gpt-5.2", Model: "openai/gpt-5.4",
AuthMethod: authMethod, AuthMethod: authMethod,
} }
case oauthProviderAnthropic: case oauthProviderAnthropic:

View file

@ -168,8 +168,8 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
} }
cfg.Providers.OpenAI.AuthMethod = "oauth" cfg.Providers.OpenAI.AuthMethod = "oauth"
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
ModelName: "gpt-5.2", ModelName: "gpt-5.4",
Model: "openai/gpt-5.2", Model: "openai/gpt-5.4",
AuthMethod: "oauth", AuthMethod: "oauth",
}) })
if err = config.SaveConfig(configPath, cfg); err != nil { if err = config.SaveConfig(configPath, cfg); err != nil {

View file

@ -1,5 +1,4 @@
# Logs # Logs
logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*

View file

@ -36,7 +36,8 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0",
"wrap-ansi": "^10.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

View file

@ -80,6 +80,9 @@ importers:
tw-animate-css: tw-animate-css:
specifier: ^1.4.0 specifier: ^1.4.0
version: 1.4.0 version: 1.4.0
wrap-ansi:
specifier: ^10.0.0
version: 10.0.0
devDependencies: devDependencies:
'@eslint/js': '@eslint/js':
specifier: ^9.39.1 specifier: ^9.39.1
@ -1837,6 +1840,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'} engines: {node: '>=8'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
ansis@4.2.0: ansis@4.2.0:
resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -3558,6 +3565,10 @@ packages:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
string-width@8.2.0:
resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==}
engines: {node: '>=20'}
stringify-entities@4.0.4: stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@ -3883,6 +3894,10 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
wrap-ansi@10.0.0:
resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==}
engines: {node: '>=20'}
wrap-ansi@6.2.0: wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -5677,6 +5692,8 @@ snapshots:
dependencies: dependencies:
color-convert: 2.0.1 color-convert: 2.0.1
ansi-styles@6.2.3: {}
ansis@4.2.0: {} ansis@4.2.0: {}
anymatch@3.1.3: anymatch@3.1.3:
@ -7628,6 +7645,11 @@ snapshots:
get-east-asian-width: 1.5.0 get-east-asian-width: 1.5.0
strip-ansi: 7.2.0 strip-ansi: 7.2.0
string-width@8.2.0:
dependencies:
get-east-asian-width: 1.5.0
strip-ansi: 7.2.0
stringify-entities@4.0.4: stringify-entities@4.0.4:
dependencies: dependencies:
character-entities-html4: 2.1.0 character-entities-html4: 2.1.0
@ -7904,6 +7926,12 @@ snapshots:
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
wrap-ansi@10.0.0:
dependencies:
ansi-styles: 6.2.3
string-width: 8.2.0
strip-ansi: 7.2.0
wrap-ansi@6.2.0: wrap-ansi@6.2.0:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0

View file

@ -13,7 +13,6 @@ import {
setLauncherConfig as updateLauncherConfig, setLauncherConfig as updateLauncherConfig,
} from "@/api/system" } from "@/api/system"
import { import {
AdvancedSection,
AgentDefaultsSection, AgentDefaultsSection,
DevicesSection, DevicesSection,
LauncherSection, LauncherSection,
@ -30,7 +29,6 @@ import {
} from "@/components/config/form-model" } from "@/components/config/form-model"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
export function ConfigPage() { export function ConfigPage() {
const { t } = useTranslation() const { t } = useTranslation()
@ -56,11 +54,7 @@ export function ConfigPage() {
}, },
}) })
const { const { data: launcherConfig, isLoading: isLauncherLoading } = useQuery({
data: launcherConfig,
isLoading: isLauncherLoading,
error: launcherError,
} = useQuery({
queryKey: ["system", "launcher-config"], queryKey: ["system", "launcher-config"],
queryFn: getLauncherConfig, queryFn: getLauncherConfig,
}) })
@ -111,10 +105,6 @@ export function ConfigPage() {
? t("pages.config.autostart_unsupported") ? t("pages.config.autostart_unsupported")
: t("pages.config.autostart_hint") : t("pages.config.autostart_hint")
const launcherHint = launcherError
? t("pages.config.launcher_load_error")
: t("pages.config.launcher_restart_hint")
const updateField = <K extends keyof CoreConfigForm>( const updateField = <K extends keyof CoreConfigForm>(
key: K, key: K,
value: CoreConfigForm[K], value: CoreConfigForm[K],
@ -287,21 +277,14 @@ export function ConfigPage() {
<AgentDefaultsSection form={form} onFieldChange={updateField} /> <AgentDefaultsSection form={form} onFieldChange={updateField} />
<Separator />
<RuntimeSection form={form} onFieldChange={updateField} /> <RuntimeSection form={form} onFieldChange={updateField} />
<Separator />
<LauncherSection <LauncherSection
launcherForm={launcherForm} launcherForm={launcherForm}
onFieldChange={updateLauncherField} onFieldChange={updateLauncherField}
launcherHint={launcherHint}
disabled={saving || isLauncherLoading} disabled={saving || isLauncherLoading}
/> />
<Separator />
<DevicesSection <DevicesSection
form={form} form={form}
onFieldChange={updateField} onFieldChange={updateField}
@ -316,10 +299,6 @@ export function ConfigPage() {
onAutoStartChange={setAutoStartEnabled} onAutoStartChange={setAutoStartEnabled}
/> />
<Separator />
<AdvancedSection />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button
variant="outline" variant="outline"

View file

@ -1,5 +1,4 @@
import { IconCode } from "@tabler/icons-react" import type { ReactNode } from "react"
import { Link } from "@tanstack/react-router"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { import {
@ -8,7 +7,13 @@ import {
type LauncherForm, type LauncherForm,
} from "@/components/config/form-model" } from "@/components/config/form-model"
import { Field, SwitchCardField } from "@/components/shared-form" import { Field, SwitchCardField } from "@/components/shared-form"
import { Button } from "@/components/ui/button" import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import {
Select, Select,
@ -29,6 +34,30 @@ type UpdateLauncherField = <K extends keyof LauncherForm>(
value: LauncherForm[K], value: LauncherForm[K],
) => void ) => void
interface ConfigSectionCardProps {
title: string
description?: string
children: ReactNode
}
function ConfigSectionCard({
title,
description,
children,
}: ConfigSectionCardProps) {
return (
<Card size="sm">
<CardHeader className="border-border border-b">
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent className="pt-0">
<div className="divide-border/70 divide-y">{children}</div>
</CardContent>
</Card>
)
}
interface AgentDefaultsSectionProps { interface AgentDefaultsSectionProps {
form: CoreConfigForm form: CoreConfigForm
onFieldChange: UpdateCoreField onFieldChange: UpdateCoreField
@ -41,89 +70,94 @@ export function AgentDefaultsSection({
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<section className="space-y-3"> <ConfigSectionCard title={t("pages.config.sections.agent")}>
<div className="space-y-4"> <Field
<Field label={t("pages.config.workspace")}
label={t("pages.config.workspace")} hint={t("pages.config.workspace_hint")}
hint={t("pages.config.workspace_hint")} layout="setting-row"
> >
<Input <Input
value={form.workspace} value={form.workspace}
onChange={(e) => onFieldChange("workspace", e.target.value)} onChange={(e) => onFieldChange("workspace", e.target.value)}
placeholder="~/.picoclaw/workspace" placeholder="~/.picoclaw/workspace"
/> />
</Field> </Field>
<SwitchCardField <SwitchCardField
label={t("pages.config.restrict_workspace")} label={t("pages.config.restrict_workspace")}
hint={t("pages.config.restrict_workspace_hint")} hint={t("pages.config.restrict_workspace_hint")}
checked={form.restrictToWorkspace} layout="setting-row"
onCheckedChange={(checked) => checked={form.restrictToWorkspace}
onFieldChange("restrictToWorkspace", checked) onCheckedChange={(checked) =>
onFieldChange("restrictToWorkspace", checked)
}
/>
<SwitchCardField
label={t("pages.config.allow_remote")}
hint={t("pages.config.allow_remote_hint")}
layout="setting-row"
checked={form.allowRemote}
onCheckedChange={(checked) => onFieldChange("allowRemote", checked)}
/>
<Field
label={t("pages.config.max_tokens")}
hint={t("pages.config.max_tokens_hint")}
layout="setting-row"
>
<Input
type="number"
min={1}
value={form.maxTokens}
onChange={(e) => onFieldChange("maxTokens", e.target.value)}
/>
</Field>
<Field
label={t("pages.config.max_tool_iterations")}
hint={t("pages.config.max_tool_iterations_hint")}
layout="setting-row"
>
<Input
type="number"
min={1}
value={form.maxToolIterations}
onChange={(e) => onFieldChange("maxToolIterations", e.target.value)}
/>
</Field>
<Field
label={t("pages.config.summarize_threshold")}
hint={t("pages.config.summarize_threshold_hint")}
layout="setting-row"
>
<Input
type="number"
min={1}
value={form.summarizeMessageThreshold}
onChange={(e) =>
onFieldChange("summarizeMessageThreshold", e.target.value)
} }
/> />
</Field>
<SwitchCardField <Field
label={t("pages.config.allow_remote")} label={t("pages.config.summarize_token_percent")}
hint={t("pages.config.allow_remote_hint")} hint={t("pages.config.summarize_token_percent_hint")}
checked={form.allowRemote} layout="setting-row"
onCheckedChange={(checked) => onFieldChange("allowRemote", checked)} >
<Input
type="number"
min={1}
max={100}
value={form.summarizeTokenPercent}
onChange={(e) =>
onFieldChange("summarizeTokenPercent", e.target.value)
}
/> />
</Field>
<Field </ConfigSectionCard>
label={t("pages.config.max_tokens")}
hint={t("pages.config.max_tokens_hint")}
>
<Input
type="number"
min={1}
value={form.maxTokens}
onChange={(e) => onFieldChange("maxTokens", e.target.value)}
/>
</Field>
<Field
label={t("pages.config.max_tool_iterations")}
hint={t("pages.config.max_tool_iterations_hint")}
>
<Input
type="number"
min={1}
value={form.maxToolIterations}
onChange={(e) => onFieldChange("maxToolIterations", e.target.value)}
/>
</Field>
<Field
label={t("pages.config.summarize_threshold")}
hint={t("pages.config.summarize_threshold_hint")}
>
<Input
type="number"
min={1}
value={form.summarizeMessageThreshold}
onChange={(e) =>
onFieldChange("summarizeMessageThreshold", e.target.value)
}
/>
</Field>
<Field
label={t("pages.config.summarize_token_percent")}
hint={t("pages.config.summarize_token_percent_hint")}
>
<Input
type="number"
min={1}
max={100}
value={form.summarizeTokenPercent}
onChange={(e) =>
onFieldChange("summarizeTokenPercent", e.target.value)
}
/>
</Field>
</div>
</section>
) )
} }
@ -139,126 +173,123 @@ export function RuntimeSection({ form, onFieldChange }: RuntimeSectionProps) {
) )
return ( return (
<section className="space-y-3"> <ConfigSectionCard title={t("pages.config.sections.runtime")}>
<div className="space-y-4"> <Field
<Field label={t("pages.config.session_scope")}
label={t("pages.config.session_scope")} hint={t("pages.config.session_scope_hint")}
hint={t("pages.config.session_scope_hint")} layout="setting-row"
>
<Select
value={form.dmScope}
onValueChange={(value) => onFieldChange("dmScope", value)}
> >
<Select <SelectTrigger className="w-full">
value={form.dmScope} <SelectValue>
onValueChange={(value) => onFieldChange("dmScope", value)} {selectedDmScopeOption
> ? t(
<SelectTrigger> selectedDmScopeOption.labelKey,
<SelectValue> selectedDmScopeOption.labelDefault,
{selectedDmScopeOption )
? t( : form.dmScope}
selectedDmScopeOption.labelKey, </SelectValue>
selectedDmScopeOption.labelDefault, </SelectTrigger>
) <SelectContent>
: form.dmScope} {DM_SCOPE_OPTIONS.map((scope) => (
</SelectValue> <SelectItem key={scope.value} value={scope.value}>
</SelectTrigger> <div className="flex flex-col gap-0.5">
<SelectContent> <span className="font-medium">{t(scope.labelKey)}</span>
{DM_SCOPE_OPTIONS.map((scope) => ( <span className="text-muted-foreground text-xs">
<SelectItem key={scope.value} value={scope.value}> {t(scope.descKey)}
<div className="flex flex-col gap-0.5"> </span>
<span className="font-medium">{t(scope.labelKey)}</span> </div>
<span className="text-muted-foreground text-xs"> </SelectItem>
{t(scope.descKey)} ))}
</span> </SelectContent>
</div> </Select>
</SelectItem> </Field>
))}
</SelectContent> <SwitchCardField
</Select> label={t("pages.config.heartbeat_enabled")}
hint={t("pages.config.heartbeat_enabled_hint")}
layout="setting-row"
checked={form.heartbeatEnabled}
onCheckedChange={(checked) =>
onFieldChange("heartbeatEnabled", checked)
}
/>
{form.heartbeatEnabled && (
<Field
label={t("pages.config.heartbeat_interval")}
hint={t("pages.config.heartbeat_interval_hint")}
layout="setting-row"
>
<Input
type="number"
min={1}
value={form.heartbeatInterval}
onChange={(e) => onFieldChange("heartbeatInterval", e.target.value)}
/>
</Field> </Field>
)}
<SwitchCardField </ConfigSectionCard>
label={t("pages.config.heartbeat_enabled")}
hint={t("pages.config.heartbeat_enabled_hint")}
checked={form.heartbeatEnabled}
onCheckedChange={(checked) =>
onFieldChange("heartbeatEnabled", checked)
}
/>
{form.heartbeatEnabled && (
<Field
label={t("pages.config.heartbeat_interval")}
hint={t("pages.config.heartbeat_interval_hint")}
>
<Input
type="number"
min={1}
value={form.heartbeatInterval}
onChange={(e) =>
onFieldChange("heartbeatInterval", e.target.value)
}
/>
</Field>
)}
</div>
</section>
) )
} }
interface LauncherSectionProps { interface LauncherSectionProps {
launcherForm: LauncherForm launcherForm: LauncherForm
onFieldChange: UpdateLauncherField onFieldChange: UpdateLauncherField
launcherHint: string
disabled: boolean disabled: boolean
} }
export function LauncherSection({ export function LauncherSection({
launcherForm, launcherForm,
onFieldChange, onFieldChange,
launcherHint,
disabled, disabled,
}: LauncherSectionProps) { }: LauncherSectionProps) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<section className="space-y-3"> <ConfigSectionCard title={t("pages.config.sections.launcher")}>
<div className="space-y-4"> <SwitchCardField
<Field label={t("pages.config.lan_access")}
label={t("pages.config.server_port")} hint={t("pages.config.lan_access_hint")}
hint={t("pages.config.server_port_hint")} layout="setting-row"
> checked={launcherForm.publicAccess}
<Input disabled={disabled}
type="number" onCheckedChange={(checked) => onFieldChange("publicAccess", checked)}
min={1} />
max={65535}
value={launcherForm.port}
disabled={disabled}
onChange={(e) => onFieldChange("port", e.target.value)}
/>
</Field>
<SwitchCardField <Field
label={t("pages.config.lan_access")} label={t("pages.config.server_port")}
hint={t("pages.config.lan_access_hint")} hint={t("pages.config.server_port_hint")}
checked={launcherForm.publicAccess} layout="setting-row"
>
<Input
type="number"
min={1}
max={65535}
value={launcherForm.port}
disabled={disabled} disabled={disabled}
onCheckedChange={(checked) => onFieldChange("publicAccess", checked)} onChange={(e) => onFieldChange("port", e.target.value)}
/> />
</Field>
<Field <Field
label={t("pages.config.allowed_cidrs")} label={t("pages.config.allowed_cidrs")}
hint={t("pages.config.allowed_cidrs_hint")} hint={t("pages.config.allowed_cidrs_hint")}
> layout="setting-row"
<Textarea controlClassName="md:max-w-md"
value={launcherForm.allowedCIDRsText} >
disabled={disabled} <Textarea
placeholder={t("pages.config.allowed_cidrs_placeholder")} value={launcherForm.allowedCIDRsText}
className="min-h-[88px]" disabled={disabled}
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)} placeholder={t("pages.config.allowed_cidrs_placeholder")}
/> className="min-h-[88px]"
</Field> onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
/>
<p className="text-muted-foreground text-xs">{launcherHint}</p> </Field>
</div> </ConfigSectionCard>
</section>
) )
} }
@ -282,52 +313,31 @@ export function DevicesSection({
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<section className="space-y-3"> <ConfigSectionCard title={t("pages.config.sections.devices")}>
<div className="space-y-4"> <SwitchCardField
<SwitchCardField label={t("pages.config.devices_enabled")}
label={t("pages.config.devices_enabled")} hint={t("pages.config.devices_enabled_hint")}
hint={t("pages.config.devices_enabled_hint")} layout="setting-row"
checked={form.devicesEnabled} checked={form.devicesEnabled}
onCheckedChange={(checked) => onCheckedChange={(checked) => onFieldChange("devicesEnabled", checked)}
onFieldChange("devicesEnabled", checked) />
}
/>
<SwitchCardField <SwitchCardField
label={t("pages.config.monitor_usb")} label={t("pages.config.monitor_usb")}
hint={t("pages.config.monitor_usb_hint")} hint={t("pages.config.monitor_usb_hint")}
checked={form.monitorUSB} layout="setting-row"
onCheckedChange={(checked) => onFieldChange("monitorUSB", checked)} checked={form.monitorUSB}
/> onCheckedChange={(checked) => onFieldChange("monitorUSB", checked)}
/>
<SwitchCardField <SwitchCardField
label={t("pages.config.autostart_label")} label={t("pages.config.autostart_label")}
hint={autoStartHint} hint={autoStartHint}
checked={autoStartEnabled} layout="setting-row"
disabled={autoStartDisabled} checked={autoStartEnabled}
onCheckedChange={onAutoStartChange} disabled={autoStartDisabled}
/> onCheckedChange={onAutoStartChange}
</div> />
</section> </ConfigSectionCard>
)
}
export function AdvancedSection() {
const { t } = useTranslation()
return (
<section className="space-y-3">
<p className="text-muted-foreground text-sm">
{t("pages.config.advanced_desc")}
</p>
<div>
<Button variant="outline" asChild>
<Link to="/config/raw">
<IconCode className="size-4" />
{t("pages.config.open_raw")}
</Link>
</Button>
</div>
</section>
) )
} }

View file

@ -1,8 +1,11 @@
import { IconAdjustments } from "@tabler/icons-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { useState } from "react" import { useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { toast } from "sonner" import { toast } from "sonner"
import { PageHeader } from "@/components/page-header"
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -15,17 +18,9 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
export function RawJsonPanel() { export function RawConfigPage() {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@ -124,81 +119,89 @@ export function RawJsonPanel() {
} }
return ( return (
<Card> <div className="flex h-full flex-col">
<CardHeader> <PageHeader title={t("pages.config.raw_json_title")}>
<CardTitle>{t("pages.config.raw_json_title")}</CardTitle> <Button variant="outline" asChild>
<CardDescription>{t("pages.config.raw_json_desc")}</CardDescription> <Link to="/config">
</CardHeader> <IconAdjustments className="size-4" />
<CardContent> {t("pages.config.back_to_visual")}
{isLoading ? ( </Link>
<div className="flex h-64 items-center justify-center"> </Button>
<p>{t("labels.loading")}</p> </PageHeader>
</div>
) : ( <div className="flex min-h-0 flex-1 flex-col p-1 lg:p-3 lg:p-6">
<div className="space-y-3"> <div className="mx-auto flex h-full min-h-0 w-full max-w-[1000px] flex-col">
{isDirty && ( {isLoading ? (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700"> <div className="flex flex-1 items-center justify-center">
{t("pages.config.unsaved_changes")} <p>{t("labels.loading")}</p>
</div> </div>
)} ) : (
<div className="bg-muted/30 relative rounded-lg border"> <div className="flex min-h-0 flex-1 flex-col gap-3">
<ScrollArea className="h-[calc(100vh-20rem)] min-h-[200px]"> {isDirty && (
<div className="shrink-0 rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
{t("pages.config.unsaved_changes")}
</div>
)}
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg border shadow-sm">
<Textarea <Textarea
value={effectiveEditorValue} value={effectiveEditorValue}
onChange={(e) => { onChange={(e) => {
setEditorValue(e.target.value) setEditorValue(e.target.value)
setIsDirty(true) setIsDirty(true)
}} }}
className="min-h-[200px] resize-none border-0 bg-transparent px-4 py-3 font-mono text-sm shadow-none focus-visible:ring-0" wrap="off"
className="h-full min-h-0 resize-none overflow-auto border-0 bg-transparent px-4 py-3 font-mono text-sm [overflow-wrap:normal] whitespace-pre shadow-none focus-visible:ring-0"
placeholder={t("pages.config.json_placeholder")} placeholder={t("pages.config.json_placeholder")}
/> />
</ScrollArea> </div>
<div className="flex shrink-0 justify-end gap-2">
<Button
variant="outline"
onClick={handleFormat}
disabled={mutation.isPending}
>
{t("pages.config.format")}
</Button>
<AlertDialog
open={showResetDialog}
onOpenChange={setShowResetDialog}
>
<AlertDialogTrigger asChild>
<Button
variant="outline"
disabled={!isDirty}
onClick={() => setShowResetDialog(true)}
>
{t("common.reset")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("pages.config.reset_confirm_title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("pages.config.reset_confirm_desc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={confirmReset}>
{t("common.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button onClick={handleSave} disabled={mutation.isPending}>
{mutation.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</div> </div>
<div className="flex justify-end space-x-2"> )}
<Button </div>
variant="outline" </div>
onClick={handleFormat} </div>
disabled={mutation.isPending}
>
{t("pages.config.format")}
</Button>
<AlertDialog
open={showResetDialog}
onOpenChange={setShowResetDialog}
>
<AlertDialogTrigger asChild>
<Button
variant="outline"
disabled={!isDirty}
onClick={() => setShowResetDialog(true)}
>
{t("common.reset")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("pages.config.reset_confirm_title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("pages.config.reset_confirm_desc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={confirmReset}>
{t("common.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button onClick={handleSave} disabled={mutation.isPending}>
{mutation.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
) )
} }

View file

@ -0,0 +1,24 @@
import { Fragment, useMemo } from "react"
import { parseAnsiSegments, wrapLogLine } from "@/lib/ansi-log"
type AnsiLogLineProps = {
line: string
wrapColumns: number
}
export function AnsiLogLine({ line, wrapColumns }: AnsiLogLineProps) {
const segments = useMemo(() => {
return parseAnsiSegments(wrapLogLine(line, wrapColumns))
}, [line, wrapColumns])
return (
<div className="break-normal whitespace-pre-wrap">
{segments.map((segment, index) => (
<Fragment key={`${index}-${segment.text.length}`}>
<span style={segment.style}>{segment.text}</span>
</Fragment>
))}
</div>
)
}

View file

@ -0,0 +1,42 @@
import { IconTrash } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import { LogsPanel } from "@/components/logs/logs-panel"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { useGatewayLogs } from "@/hooks/use-gateway-logs"
import { useLogWrapColumns } from "@/hooks/use-log-wrap-columns"
export function LogsPage() {
const { t } = useTranslation()
const { clearLogs, clearing, logs } = useGatewayLogs()
const { contentRef, measureRef, wrapColumns } = useLogWrapColumns()
return (
<div className="flex h-full flex-col">
<PageHeader
title={t("navigation.logs")}
children={
<Button
variant="outline"
size="sm"
onClick={clearLogs}
disabled={logs.length === 0 || clearing}
>
<IconTrash className="size-4" />
{t("pages.logs.clear")}
</Button>
}
/>
<div className="flex flex-1 flex-col gap-4 overflow-hidden p-4 sm:p-8">
<LogsPanel
logs={logs}
wrapColumns={wrapColumns}
contentRef={contentRef}
measureRef={measureRef}
/>
</div>
</div>
)
}

View file

@ -0,0 +1,55 @@
import { type RefObject, useEffect, useRef } from "react"
import { useTranslation } from "react-i18next"
import { AnsiLogLine } from "@/components/logs/ansi-log-line"
import { ScrollArea } from "@/components/ui/scroll-area"
type LogsPanelProps = {
logs: string[]
wrapColumns: number
contentRef: RefObject<HTMLDivElement | null>
measureRef: RefObject<HTMLSpanElement | null>
}
export function LogsPanel({
logs,
wrapColumns,
contentRef,
measureRef,
}: LogsPanelProps) {
const { t } = useTranslation()
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [logs])
return (
<div className="relative flex-1 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950 text-zinc-100">
<ScrollArea className="h-full">
<div
ref={contentRef}
className="relative p-4 font-mono text-sm leading-relaxed"
>
<span
ref={measureRef}
aria-hidden
className="pointer-events-none invisible absolute font-mono text-sm"
>
0
</span>
{logs.length === 0 ? (
<div className="text-zinc-500 italic">{t("pages.logs.empty")}</div>
) : (
logs.map((log, index) => (
<AnsiLogLine key={index} line={log} wrapColumns={wrapColumns} />
))
)}
<div ref={scrollRef} />
</div>
</ScrollArea>
</div>
)
}

View file

@ -9,6 +9,9 @@ import {
} from "@/components/ui/field" } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
type FieldLayout = "default" | "setting-row"
interface FieldProps { interface FieldProps {
label: string label: string
@ -16,9 +19,45 @@ interface FieldProps {
error?: string error?: string
required?: boolean required?: boolean
children: ReactNode children: ReactNode
layout?: FieldLayout
controlClassName?: string
} }
export function Field({ label, hint, error, required, children }: FieldProps) { export function Field({
label,
hint,
error,
required,
children,
layout = "default",
controlClassName,
}: FieldProps) {
if (layout === "setting-row") {
return (
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_minmax(240px,320px)] md:items-center md:gap-6">
<div className="max-w-full space-y-1 md:max-w-[clamp(18rem,42vw,28rem)]">
<FieldLabel>
{label}
{required && <span className="text-destructive ml-1">*</span>}
</FieldLabel>
{hint && (
<FieldDescription className="text-xs leading-normal break-words">
{hint}
</FieldDescription>
)}
</div>
<div className={cn("w-full md:justify-self-center", controlClassName)}>
{children}
</div>
{error && (
<FieldDescription className="text-destructive text-xs leading-normal md:col-start-2">
{error}
</FieldDescription>
)}
</div>
)
}
return ( return (
<UiField className="gap-2.5"> <UiField className="gap-2.5">
<div className="space-y-1"> <div className="space-y-1">
@ -85,6 +124,7 @@ interface SwitchCardFieldProps {
ariaLabel?: string ariaLabel?: string
disabled?: boolean disabled?: boolean
children?: ReactNode children?: ReactNode
layout?: FieldLayout
} }
export function SwitchCardField({ export function SwitchCardField({
@ -96,7 +136,37 @@ export function SwitchCardField({
ariaLabel, ariaLabel,
disabled, disabled,
children, children,
layout = "default",
}: SwitchCardFieldProps) { }: SwitchCardFieldProps) {
if (layout === "setting-row") {
return (
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_auto] md:items-center md:gap-6">
<div className="max-w-full min-w-0 md:max-w-[clamp(18rem,42vw,28rem)]">
<p className="text-sm font-medium">{label}</p>
{hint && (
<p className="text-muted-foreground mt-0.5 text-xs leading-normal break-words">
{hint}
</p>
)}
</div>
<div className="flex items-center md:justify-self-center">
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
aria-label={ariaLabel ?? label}
/>
</div>
{children && <div className="md:col-start-2">{children}</div>}
{error && (
<p className="text-destructive text-xs leading-normal md:col-start-2">
{error}
</p>
)}
</div>
)
}
return ( return (
<div className="border-border/60 bg-background rounded-lg border px-4 py-3"> <div className="border-border/60 bg-background rounded-lg border px-4 py-3">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">

View file

@ -0,0 +1,96 @@
import { useAtomValue } from "jotai"
import { useEffect, useRef, useState } from "react"
import { clearGatewayLogs, getGatewayStatus } from "@/api/gateway"
import { gatewayAtom } from "@/store/gateway"
export function useGatewayLogs() {
const [logs, setLogs] = useState<string[]>([])
const [clearing, setClearing] = useState(false)
const logOffsetRef = useRef(0)
const logRunIdRef = useRef(-1)
const syncTokenRef = useRef(0)
const gateway = useAtomValue(gatewayAtom)
const clearLogs = async () => {
setClearing(true)
try {
const data = await clearGatewayLogs()
syncTokenRef.current += 1
setLogs([])
logOffsetRef.current = data.log_total ?? 0
if (data.log_run_id !== undefined) {
logRunIdRef.current = data.log_run_id
}
} catch {
// Ignore clear failures silently to avoid noisy transient errors.
} finally {
setClearing(false)
}
}
useEffect(() => {
let mounted = true
let timeout: ReturnType<typeof setTimeout>
const fetchLogs = async () => {
if (
!mounted ||
(gateway.status !== "running" && gateway.status !== "starting")
) {
if (mounted) {
timeout = setTimeout(fetchLogs, 1000)
}
return
}
try {
const requestToken = syncTokenRef.current
const requestOffset = logOffsetRef.current
const requestRunId = logRunIdRef.current
const data = await getGatewayStatus({
log_offset: requestOffset,
log_run_id: requestRunId,
})
if (!mounted || requestToken !== syncTokenRef.current) {
return
}
if (data.log_run_id !== undefined && data.log_run_id !== requestRunId) {
logRunIdRef.current = data.log_run_id
logOffsetRef.current = 0
if (data.logs) {
setLogs(data.logs)
logOffsetRef.current = data.log_total || data.logs.length
}
} else if (data.logs && data.logs.length > 0) {
const nextLogs = data.logs
setLogs((prev) => [...prev, ...nextLogs])
logOffsetRef.current =
data.log_total || logOffsetRef.current + nextLogs.length
}
} catch {
// Ignore simple fetch errors during polling.
} finally {
if (mounted) {
timeout = setTimeout(fetchLogs, 1000)
}
}
}
fetchLogs()
return () => {
mounted = false
clearTimeout(timeout)
}
}, [gateway.status])
return {
clearLogs,
clearing,
logs,
}
}

View file

@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from "react"
const DEFAULT_WRAP_COLUMNS = 120
const MIN_WRAP_COLUMNS = 20
export function useLogWrapColumns() {
const [wrapColumns, setWrapColumns] = useState(DEFAULT_WRAP_COLUMNS)
const contentRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
const content = contentRef.current
const measure = measureRef.current
if (!content || !measure) {
return
}
const updateWrapColumns = () => {
const contentWidth = content.clientWidth
const charWidth = measure.getBoundingClientRect().width
if (!contentWidth || !charWidth) {
return
}
const nextColumns = Math.max(
Math.floor(contentWidth / charWidth) - 1,
MIN_WRAP_COLUMNS,
)
setWrapColumns((current) =>
current === nextColumns ? current : nextColumns,
)
}
updateWrapColumns()
const observer = new ResizeObserver(updateWrapColumns)
observer.observe(content)
return () => {
observer.disconnect()
}
}, [])
return {
contentRef,
measureRef,
wrapColumns,
}
}

View file

@ -331,33 +331,10 @@
"pages": { "pages": {
"agent": { "agent": {
"load_error": "Failed to load agent support information.", "load_error": "Failed to load agent support information.",
"stats": {
"workspace": "Workspace",
"workspace_hint": "The default agent workspace used for runtime files and workspace skills.",
"skills": "Available Skills",
"skills_hint": "Skills discovered from workspace, global, and builtin roots.",
"tools": "Enabled Tools",
"tools_hint": "{{blocked}} blocked by missing dependencies."
},
"skills": { "skills": {
"title": "Skills",
"description": "Skills are loaded from the workspace, global PicoClaw home, and builtin directories.", "description": "Skills are loaded from the workspace, global PicoClaw home, and builtin directories.",
"hero_title": "Skill Library",
"hero_description": "Browse every capability package the agent can load, then drill straight into the effective SKILL.md without leaving the page.",
"stats": {
"total": "Total Skills",
"workspace": "Workspace",
"shared": "Shared"
},
"empty": "No skills are currently available.", "empty": "No skills are currently available.",
"import": "Import Skill", "import": "Import Skill",
"import_title": "Import Skill",
"import_description": "Create a workspace skill by uploading a markdown file as the new SKILL.md.",
"import_name": "Skill Name",
"import_name_placeholder": "e.g. my-workflow",
"import_file": "Markdown File",
"import_file_hint": "Upload a .md file. The backend stores it as workspace/skills/<name>/SKILL.md.",
"import_confirm": "Import Skill",
"import_success": "Skill imported.", "import_success": "Skill imported.",
"import_error": "Failed to import skill.", "import_error": "Failed to import skill.",
"view": "View", "view": "View",
@ -371,28 +348,11 @@
"viewer_description": "Read the current effective SKILL.md content here.", "viewer_description": "Read the current effective SKILL.md content here.",
"loading_detail": "Loading skill content...", "loading_detail": "Loading skill content...",
"load_detail_error": "Failed to load skill content.", "load_detail_error": "Failed to load skill content.",
"source": "Source",
"path": "Skill Path", "path": "Skill Path",
"no_description": "No description provided.", "no_description": "No description provided."
"sources": {
"workspace": "Workspace",
"global": "Global",
"builtin": "Builtin"
},
"errors": {
"file_required": "Please choose a markdown file to import."
}
}, },
"tools": { "tools": {
"title": "Tools",
"description": "This view reflects whether each agent tool is enabled, disabled, or blocked by a missing prerequisite.", "description": "This view reflects whether each agent tool is enabled, disabled, or blocked by a missing prerequisite.",
"hero_title": "Tool Surface",
"hero_description": "Inspect what the agent can actually call right now, which capabilities are blocked, and where each tool is controlled in config.",
"stats": {
"enabled": "Enabled",
"blocked": "Blocked",
"categories": "Categories"
},
"empty": "No tools are available.", "empty": "No tools are available.",
"enable": "Enable", "enable": "Enable",
"disable": "Disable", "disable": "Disable",
@ -468,13 +428,15 @@
"allowed_cidrs": "Allowed Network CIDRs", "allowed_cidrs": "Allowed Network CIDRs",
"allowed_cidrs_hint": "Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.", "allowed_cidrs_hint": "Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.",
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8", "allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
"launcher_load_error": "Failed to load service parameters.", "sections": {
"launcher_restart_hint": "Service parameter changes apply after restarting PicoClaw Web.", "agent": "Agent",
"advanced_desc": "Open the raw JSON page to edit every field directly.", "runtime": "Runtime",
"launcher": "Service",
"devices": "Devices"
},
"open_raw": "Raw Config", "open_raw": "Raw Config",
"back_to_visual": "Visual Config", "back_to_visual": "Visual Config",
"raw_json_title": "Raw JSON Configuration", "raw_json_title": "Raw JSON Configuration",
"raw_json_desc": "Advanced users can directly edit the raw JSON configuration below.",
"json_placeholder": "Enter valid JSON configuration...", "json_placeholder": "Enter valid JSON configuration...",
"save_success": "Configuration saved successfully.", "save_success": "Configuration saved successfully.",
"save_error": "Failed to save configuration.", "save_error": "Failed to save configuration.",
@ -488,7 +450,6 @@
"unsaved_changes": "You have unsaved changes." "unsaved_changes": "You have unsaved changes."
}, },
"logs": { "logs": {
"description": "System logs and monitoring.",
"clear": "Clear logs", "clear": "Clear logs",
"empty": "Waiting for logs..." "empty": "Waiting for logs..."
} }

View file

@ -331,33 +331,10 @@
"pages": { "pages": {
"agent": { "agent": {
"load_error": "加载 Agent 支持信息失败。", "load_error": "加载 Agent 支持信息失败。",
"stats": {
"workspace": "工作目录",
"workspace_hint": "默认 Agent 运行时使用的工作目录,也用于加载工作区技能。",
"skills": "可用技能数",
"skills_hint": "从工作区、全局目录和内置目录发现的技能。",
"tools": "已启用工具",
"tools_hint": "其中 {{blocked}} 个因依赖未满足而不可用。"
},
"skills": { "skills": {
"title": "技能",
"description": "技能会从工作区、PicoClaw 全局目录和内置目录中加载。", "description": "技能会从工作区、PicoClaw 全局目录和内置目录中加载。",
"hero_title": "技能库",
"hero_description": "在这里查看 Agent 当前可加载的能力包,并且不离开页面就能直接阅读生效后的 SKILL.md。",
"stats": {
"total": "技能总数",
"workspace": "工作区技能",
"shared": "共享技能"
},
"empty": "当前没有可用技能。", "empty": "当前没有可用技能。",
"import": "导入技能", "import": "导入技能",
"import_title": "导入技能",
"import_description": "通过上传 Markdown 文件创建工作区技能,文件会保存为新的 SKILL.md。",
"import_name": "技能名称",
"import_name_placeholder": "例如 my-workflow",
"import_file": "Markdown 文件",
"import_file_hint": "上传一个 .md 文件。后端会保存到 workspace/skills/<name>/SKILL.md。",
"import_confirm": "导入技能",
"import_success": "技能导入成功。", "import_success": "技能导入成功。",
"import_error": "导入技能失败。", "import_error": "导入技能失败。",
"view": "查看", "view": "查看",
@ -371,28 +348,11 @@
"viewer_description": "这里展示当前生效的 SKILL.md 内容。", "viewer_description": "这里展示当前生效的 SKILL.md 内容。",
"loading_detail": "正在加载技能内容...", "loading_detail": "正在加载技能内容...",
"load_detail_error": "加载技能内容失败。", "load_detail_error": "加载技能内容失败。",
"source": "来源",
"path": "技能路径", "path": "技能路径",
"no_description": "未提供描述。", "no_description": "未提供描述。"
"sources": {
"workspace": "工作区",
"global": "全局",
"builtin": "内置"
},
"errors": {
"file_required": "请先选择要导入的 Markdown 文件。"
}
}, },
"tools": { "tools": {
"title": "工具",
"description": "这里展示每个 Agent 工具当前是已启用、已禁用,还是被依赖条件阻塞。", "description": "这里展示每个 Agent 工具当前是已启用、已禁用,还是被依赖条件阻塞。",
"hero_title": "工具面板",
"hero_description": "集中查看 Agent 现在真正可调用的工具、被阻塞的能力,以及它们分别受哪项配置控制。",
"stats": {
"enabled": "已启用",
"blocked": "被阻塞",
"categories": "分类数"
},
"empty": "当前没有可用工具。", "empty": "当前没有可用工具。",
"enable": "启用", "enable": "启用",
"disable": "禁用", "disable": "禁用",
@ -465,16 +425,18 @@
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口。", "server_port_hint": "PicoClaw Web 的 HTTP 监听端口。",
"lan_access": "启用局域网访问", "lan_access": "启用局域网访问",
"lan_access_hint": "允许局域网中的其他设备访问当前服务。", "lan_access_hint": "允许局域网中的其他设备访问当前服务。",
"allowed_cidrs": "允许访问网段CIDR", "allowed_cidrs": "允许访问网段",
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。", "allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。",
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8", "allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
"launcher_load_error": "加载服务参数失败。", "sections": {
"launcher_restart_hint": "服务参数变更需重启 PicoClaw Web 后生效。", "agent": "智能体",
"advanced_desc": "可打开原始 JSON 页面直接编辑全部字段。", "runtime": "运行时",
"launcher": "服务参数",
"devices": "设备"
},
"open_raw": "原始配置", "open_raw": "原始配置",
"back_to_visual": "可视化配置", "back_to_visual": "可视化配置",
"raw_json_title": "原始 JSON 配置", "raw_json_title": "原始 JSON 配置",
"raw_json_desc": "高级用户可以直接编辑下方的原始 JSON 配置。",
"json_placeholder": "请输入有效的 JSON 配置...", "json_placeholder": "请输入有效的 JSON 配置...",
"save_success": "配置保存成功。", "save_success": "配置保存成功。",
"save_error": "配置保存失败。", "save_error": "配置保存失败。",
@ -488,7 +450,6 @@
"unsaved_changes": "您有未保存的更改。" "unsaved_changes": "您有未保存的更改。"
}, },
"logs": { "logs": {
"description": "系统日志和监控。",
"clear": "清空日志", "clear": "清空日志",
"empty": "等待日志中..." "empty": "等待日志中..."
} }

View file

@ -0,0 +1,290 @@
import type { CSSProperties } from "react"
import wrapAnsi from "wrap-ansi"
export type AnsiSegment = {
style: CSSProperties
text: string
}
type AnsiState = {
background?: string
bold?: boolean
dim?: boolean
foreground?: string
italic?: boolean
strikethrough?: boolean
underline?: boolean
underlineColor?: string
}
const ANSI_PATTERN = new RegExp(String.raw`\u001B\[([0-9;]*)m`, "g")
const ANSI_COLORS = [
"#4b5563",
"#f87171",
"#4ade80",
"#facc15",
"#60a5fa",
"#c084fc",
"#22d3ee",
"#f3f4f6",
]
const ANSI_BRIGHT_COLORS = [
"#6b7280",
"#fb7185",
"#86efac",
"#fde047",
"#93c5fd",
"#e879f9",
"#67e8f9",
"#ffffff",
]
function cloneAnsiState(state: AnsiState): AnsiState {
return { ...state }
}
function ansi256ToHex(code: number): string {
if (code < 0 || code > 255) {
return "inherit"
}
if (code < 8) {
return ANSI_COLORS[code]
}
if (code < 16) {
return ANSI_BRIGHT_COLORS[code - 8]
}
if (code < 232) {
const index = code - 16
const red = Math.floor(index / 36)
const green = Math.floor((index % 36) / 6)
const blue = index % 6
const scale = [0, 95, 135, 175, 215, 255]
return `rgb(${scale[red]}, ${scale[green]}, ${scale[blue]})`
}
const gray = 8 + (code - 232) * 10
return `rgb(${gray}, ${gray}, ${gray})`
}
function codeToColor(code: number): string | undefined {
if (code >= 30 && code <= 37) {
return ANSI_COLORS[code - 30]
}
if (code >= 40 && code <= 47) {
return ANSI_COLORS[code - 40]
}
if (code >= 90 && code <= 97) {
return ANSI_BRIGHT_COLORS[code - 90]
}
if (code >= 100 && code <= 107) {
return ANSI_BRIGHT_COLORS[code - 100]
}
if (code === 39 || code === 49) {
return undefined
}
}
function applyExtendedColor(
state: AnsiState,
codes: number[],
index: number,
target: "foreground" | "background" | "underlineColor",
): number {
const mode = codes[index + 1]
if (mode === 5) {
const colorCode = codes[index + 2]
if (colorCode !== undefined) {
state[target] = ansi256ToHex(colorCode)
return index + 2
}
}
if (mode === 2) {
const red = codes[index + 2]
const green = codes[index + 3]
const blue = codes[index + 4]
if (red !== undefined && green !== undefined && blue !== undefined) {
state[target] = `rgb(${red}, ${green}, ${blue})`
return index + 4
}
}
return index
}
function styleToCss(style: AnsiState): CSSProperties {
return {
backgroundColor: style.background,
color: style.foreground,
fontStyle: style.italic ? "italic" : undefined,
fontWeight: style.bold ? 700 : undefined,
opacity: style.dim ? 0.7 : undefined,
textDecorationColor: style.underlineColor,
textDecorationLine:
[
style.underline ? "underline" : "",
style.strikethrough ? "line-through" : "",
]
.filter(Boolean)
.join(" ") || undefined,
}
}
export function parseAnsiSegments(input: string): AnsiSegment[] {
const segments: AnsiSegment[] = []
const state: AnsiState = {}
let lastIndex = 0
let match: RegExpExecArray | null
const pushText = (text: string) => {
if (!text) {
return
}
segments.push({
style: styleToCss(cloneAnsiState(state)),
text,
})
}
ANSI_PATTERN.lastIndex = 0
while ((match = ANSI_PATTERN.exec(input)) !== null) {
pushText(input.slice(lastIndex, match.index))
const codes = (match[1] || "0")
.split(";")
.map((value) => (value === "" ? 0 : Number.parseInt(value, 10)))
.filter((value) => Number.isFinite(value))
for (let index = 0; index < codes.length; index += 1) {
const code = codes[index]
if (code === 0) {
Object.keys(state).forEach((key) => {
delete state[key as keyof AnsiState]
})
continue
}
if (code === 1) {
state.bold = true
continue
}
if (code === 2) {
state.dim = true
continue
}
if (code === 3) {
state.italic = true
continue
}
if (code === 4) {
state.underline = true
continue
}
if (code === 9) {
state.strikethrough = true
continue
}
if (code === 21 || code === 22) {
delete state.bold
delete state.dim
continue
}
if (code === 23) {
delete state.italic
continue
}
if (code === 24) {
delete state.underline
continue
}
if (code === 29) {
delete state.strikethrough
continue
}
if (code === 39) {
delete state.foreground
continue
}
if (code === 49) {
delete state.background
continue
}
if (code === 59) {
delete state.underlineColor
continue
}
if (code === 38) {
index = applyExtendedColor(state, codes, index, "foreground")
continue
}
if (code === 48) {
index = applyExtendedColor(state, codes, index, "background")
continue
}
if (code === 58) {
index = applyExtendedColor(state, codes, index, "underlineColor")
continue
}
if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
state.foreground = codeToColor(code)
continue
}
if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
state.background = codeToColor(code)
}
}
lastIndex = ANSI_PATTERN.lastIndex
}
pushText(input.slice(lastIndex))
if (segments.length === 0) {
return [{ style: {}, text: input }]
}
return segments
}
export function wrapLogLine(line: string, columns: number): string {
const normalized = line.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
if (columns < 20) {
return normalized
}
return wrapAnsi(normalized, columns, {
hard: true,
trim: false,
wordWrap: false,
})
}

View file

@ -1,34 +1,7 @@
import { IconAdjustments } from "@tabler/icons-react" import { createFileRoute } from "@tanstack/react-router"
import { Link, createFileRoute } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { RawJsonPanel } from "@/components/config/raw-json-panel" import { RawConfigPage } from "@/components/config/raw-config-page"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
export const Route = createFileRoute("/config/raw")({ export const Route = createFileRoute("/config/raw")({
component: RawConfigPage, component: RawConfigPage,
}) })
function RawConfigPage() {
const { t } = useTranslation()
return (
<div className="flex h-full flex-col">
<PageHeader title={t("pages.config.raw_json_title")}>
<Button variant="outline" asChild>
<Link to="/config">
<IconAdjustments className="size-4" />
{t("pages.config.back_to_visual")}
</Link>
</Button>
</PageHeader>
<div className="flex-1 overflow-auto p-3 lg:p-6">
<div className="mx-auto max-w-4xl">
<RawJsonPanel />
</div>
</div>
</div>
)
}

View file

@ -1,156 +1,7 @@
import { IconTrash } from "@tabler/icons-react"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { useAtomValue } from "jotai"
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { clearGatewayLogs, getGatewayStatus } from "@/api/gateway" import { LogsPage } from "@/components/logs/logs-page"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { gatewayAtom } from "@/store/gateway"
export const Route = createFileRoute("/logs")({ export const Route = createFileRoute("/logs")({
component: LogsPage, component: LogsPage,
}) })
function LogsPage() {
const { t } = useTranslation()
const [logs, setLogs] = useState<string[]>([])
const [clearing, setClearing] = useState(false)
const logOffsetRef = useRef<number>(0)
const logRunIdRef = useRef<number>(-1)
const syncTokenRef = useRef<number>(0)
const scrollRef = useRef<HTMLDivElement>(null)
const gateway = useAtomValue(gatewayAtom)
const handleClearLogs = async () => {
setClearing(true)
try {
const data = await clearGatewayLogs()
syncTokenRef.current += 1
setLogs([])
logOffsetRef.current = data.log_total ?? 0
if (data.log_run_id !== undefined) {
logRunIdRef.current = data.log_run_id
}
} catch {
// Ignore clear failures silently to avoid noisy transient errors.
} finally {
setClearing(false)
}
}
useEffect(() => {
let mounted = true
let timeout: ReturnType<typeof setTimeout>
const fetchLogs = async () => {
// Only fetch logs if the gateway is running or starting
if (
!mounted ||
(gateway.status !== "running" && gateway.status !== "starting")
) {
if (mounted) {
// Still poll the state, but maybe at a slower rate, or we just rely on SSE for status
// and restart fast polling when it's running. Let's just re-evaluate every second
timeout = setTimeout(fetchLogs, 1000)
}
return
}
try {
const requestToken = syncTokenRef.current
const requestOffset = logOffsetRef.current
const requestRunId = logRunIdRef.current
const data = await getGatewayStatus({
log_offset: requestOffset,
log_run_id: requestRunId,
})
if (!mounted || requestToken !== syncTokenRef.current) return
if (data.log_run_id !== undefined && data.log_run_id !== requestRunId) {
logRunIdRef.current = data.log_run_id
logOffsetRef.current = 0
if (data.logs) {
setLogs(data.logs)
logOffsetRef.current = data.log_total || data.logs.length
}
} else if (data.logs && data.logs.length > 0) {
setLogs((prev) => [...prev, ...data.logs!])
logOffsetRef.current =
data.log_total || logOffsetRef.current + data.logs.length
}
} catch {
// Ignore simple fetch errors during polling
} finally {
if (mounted) {
timeout = setTimeout(fetchLogs, 1000)
}
}
}
fetchLogs()
return () => {
mounted = false
clearTimeout(timeout)
}
}, [gateway.status])
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [logs])
return (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.logs")} />
<div className="flex flex-1 flex-col overflow-hidden p-4 sm:p-8">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{t("navigation.logs")}
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{t("pages.logs.description")}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleClearLogs}
disabled={logs.length === 0 || clearing}
>
<IconTrash className="size-4" />
{t("pages.logs.clear")}
</Button>
</div>
<div className="bg-muted/30 relative flex-1 overflow-hidden rounded-lg border">
<ScrollArea className="h-full">
<div className="p-4 font-mono text-sm leading-relaxed">
{logs.length === 0 ? (
<div className="text-muted-foreground italic">
{t("pages.logs.empty")}
</div>
) : (
logs.map((log, i) => (
<div key={i} className="break-all whitespace-pre-wrap">
{log}
</div>
))
)}
<div ref={scrollRef} />
</div>
</ScrollArea>
</div>
</div>
</div>
)
}

View file

@ -59,7 +59,7 @@ Default model is `google/gemini-3-flash-preview` if none is set.
Optional config file: `~/.summarize/config.json` Optional config file: `~/.summarize/config.json`
```json ```json
{ "model": "openai/gpt-5.2" } { "model": "openai/gpt-5.4" }
``` ```
Optional services: Optional services: