Merge branch 'sipeed:main' into main
This commit is contained in:
commit
b11f589c12
49 changed files with 1572 additions and 891 deletions
144
.github/workflows/nightly.yml
vendored
144
.github/workflows/nightly.yml
vendored
|
|
@ -9,64 +9,37 @@ permissions:
|
|||
contents: read
|
||||
|
||||
jobs:
|
||||
create-tag:
|
||||
name: Create Git Tag
|
||||
nightly:
|
||||
name: Nightly Build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
changelog: ${{ steps.version.outputs.changelog }}
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate and push tag
|
||||
- name: Compute version
|
||||
id: version
|
||||
run: |
|
||||
DATE=$(date -u +%Y%m%d)
|
||||
SHA=$(git rev-parse --short=8 HEAD)
|
||||
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
|
||||
TAG="v0.0.0-nightly.${DATE}.${SHA}"
|
||||
VERSION="v0.0.0-nightly.${DATE}.${SHA}"
|
||||
else
|
||||
TAG="${BASE_VERSION}-nightly.${DATE}.${SHA}"
|
||||
VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}"
|
||||
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
|
||||
COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...${TAG}"
|
||||
COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...main"
|
||||
fi
|
||||
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$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 }}
|
||||
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Go from go.mod
|
||||
id: setup-go
|
||||
|
|
@ -95,6 +68,16 @@ jobs:
|
|||
username: ${{ github.actor }}
|
||||
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
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
|
|
@ -106,6 +89,7 @@ jobs:
|
|||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
|
||||
NIGHTLY_BUILD: "true"
|
||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||
|
|
@ -113,26 +97,14 @@ jobs:
|
|||
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||
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
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.create-tag.outputs.tag }}
|
||||
TITLE: ${{ needs.create-tag.outputs.version }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
CHANGELOG='${{ needs.create-tag.outputs.changelog }}'
|
||||
CHANGELOG='${{ steps.version.outputs.changelog }}'
|
||||
NOTES=$(cat <<EOF
|
||||
Nightly build for **${TITLE}**
|
||||
Nightly build for **${VERSION}**
|
||||
|
||||
This is an automated build and may be unstable. Use with caution.
|
||||
|
||||
|
|
@ -140,65 +112,27 @@ jobs:
|
|||
EOF
|
||||
)
|
||||
|
||||
# Download assets from the newly created release if it exists,
|
||||
# otherwise fall back to using locally built dist/ artifacts.
|
||||
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
|
||||
gh release delete nightly --cleanup-tag -y 2>/dev/null || true
|
||||
|
||||
# Delete existing nightly release and tag to avoid conflicts
|
||||
echo "Deleting existing nightly release and tag..."
|
||||
gh release delete nightly --cleanup-tag -y || true
|
||||
git push origin :refs/tags/nightly || true
|
||||
# Force-update nightly tag to current HEAD
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
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 \
|
||||
--title "Nightly Build" \
|
||||
--notes "$NOTES" \
|
||||
--target "${{ github.sha }}" \
|
||||
--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
|
||||
|
|
|
|||
|
|
@ -116,9 +116,9 @@ dockers_v2:
|
|||
- picoclaw
|
||||
images:
|
||||
- "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:
|
||||
- "{{ .Tag }}"
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}'
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
|
||||
platforms:
|
||||
- linux/amd64
|
||||
|
|
@ -133,9 +133,9 @@ dockers_v2:
|
|||
- picoclaw-launcher-tui
|
||||
images:
|
||||
- "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:
|
||||
- "{{ .Tag }}-launcher"
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}'
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}'
|
||||
platforms:
|
||||
- linux/amd64
|
||||
|
|
@ -215,6 +215,7 @@ changelog:
|
|||
# lzma: true
|
||||
|
||||
release:
|
||||
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
|
||||
footer: >-
|
||||
|
||||
---
|
||||
|
|
|
|||
61
README.fr.md
61
README.fr.md
|
|
@ -1,5 +1,5 @@
|
|||
<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>
|
||||
|
||||
|
|
@ -206,9 +206,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
|||
### 🚀 Démarrage Rapide
|
||||
|
||||
> [!TIP]
|
||||
> Configurez votre clé API dans `~/.picoclaw/config.json`.
|
||||
> 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é.
|
||||
> 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).
|
||||
|
||||
**1. Initialiser**
|
||||
|
||||
|
|
@ -222,8 +220,13 @@ picoclaw onboard
|
|||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "ark-code-latest",
|
||||
"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",
|
||||
"request_timeout": 300,
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
|
|
@ -231,7 +234,7 @@ picoclaw onboard
|
|||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt4"
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"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) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **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 | - |
|
||||
| **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) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
|
||||
| **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_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "ark-code-latest",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
|
|
@ -1006,7 +1015,7 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
|||
],
|
||||
"agents": {
|
||||
"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**
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**VolcEngine (Doubao)**
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
|
@ -1061,14 +1079,14 @@ Configurez plusieurs points de terminaison pour le même nom de modèle—PicoCl
|
|||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api1.example.com/v1",
|
||||
"api_key": "sk-key1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api2.example.com/v1",
|
||||
"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 |
|
||||
| ---------------- | -------------------- | ------------------------------------- |
|
||||
| **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 |
|
||||
| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) |
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||
</div>
|
||||
|
|
|
|||
61
README.ja.md
61
README.ja.md
|
|
@ -1,5 +1,5 @@
|
|||
<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>
|
||||
|
||||
|
|
@ -168,9 +168,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
|||
### 🚀 クイックスタート(ネイティブ)
|
||||
|
||||
> [!TIP]
|
||||
> `~/.picoclaw/config.json` に API キーを設定してください。
|
||||
> 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 クエリ無料)
|
||||
> `~/.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 クエリ無料)。
|
||||
|
||||
**1. 初期化**
|
||||
|
||||
|
|
@ -184,8 +182,13 @@ picoclaw onboard
|
|||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "ark-code-latest",
|
||||
"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",
|
||||
"request_timeout": 300,
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
|
|
@ -193,7 +196,7 @@ picoclaw onboard
|
|||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt4"
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
|
|
@ -918,8 +921,9 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
|
||||
| **Volcengine** | `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 | - |
|
||||
| **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) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
|
@ -930,8 +934,13 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "ark-code-latest",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
|
|
@ -947,7 +956,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "gpt-5.2"
|
||||
"model": "gpt-5.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -958,8 +967,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
**OpenAI**
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**VolcEngine (Doubao)**
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
|
@ -1002,14 +1020,14 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api1.example.com/v1",
|
||||
"api_key": "sk-key1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api2.example.com/v1",
|
||||
"api_key": "sk-key2"
|
||||
}
|
||||
|
|
@ -1120,9 +1138,16 @@ Web 検索を有効にするには:
|
|||
| サービス | 無料枠 | ユースケース |
|
||||
|---------|--------|------------|
|
||||
| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) |
|
||||
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
||||
| **Volcengine CodingPlan** | 9.9元/初月 | 中国ユーザーに最適、複数のSOTAモデル(Doubao、DeepSeek等) |
|
||||
| **Zhipu** | 月 200K トークン | 中国ユーザーに適している |
|
||||
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
|
||||
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
||||
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
|
||||
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
||||
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||
</div>
|
||||
|
|
|
|||
62
README.md
62
README.md
|
|
@ -1,5 +1,5 @@
|
|||
<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>
|
||||
|
||||
|
|
@ -227,9 +227,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
|||
### 🚀 Quick Start
|
||||
|
||||
> [!TIP]
|
||||
> Set your API key in `~/.picoclaw/config.json`.
|
||||
> Get API keys: [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.
|
||||
> 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).
|
||||
|
||||
**1. Initialize**
|
||||
|
||||
|
|
@ -244,7 +242,7 @@ picoclaw onboard
|
|||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model_name": "gpt4",
|
||||
"model_name": "gpt-5.4",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
|
|
@ -252,8 +250,13 @@ picoclaw onboard
|
|||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "ark-code-latest",
|
||||
"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",
|
||||
"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 |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **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 | - |
|
||||
| **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) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
|
||||
| **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_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "ark-code-latest",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
|
|
@ -1060,7 +1069,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
],
|
||||
"agents": {
|
||||
"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
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**VolcEngine (Doubao)**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
|
@ -1152,14 +1171,14 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
|
|||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api1.example.com/v1",
|
||||
"api_key": "sk-key1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api2.example.com/v1",
|
||||
"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 |
|
||||
| ---------------- | ------------------------ | ------------------------------------- |
|
||||
| **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 |
|
||||
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
|
||||
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
||||
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
||||
| **LongCat** | Up to 5M tokens/day | Fast inference (free tier) |
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<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>
|
||||
|
||||
|
|
@ -207,9 +207,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
|||
### 🚀 Início Rápido
|
||||
|
||||
> [!TIP]
|
||||
> Configure sua API key em `~/.picoclaw/config.json`.
|
||||
> Obtenha API keys: [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.
|
||||
> 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).
|
||||
|
||||
**1. Inicializar**
|
||||
|
||||
|
|
@ -223,8 +221,13 @@ picoclaw onboard
|
|||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "ark-code-latest",
|
||||
"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",
|
||||
"request_timeout": 300,
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
|
|
@ -232,7 +235,7 @@ picoclaw onboard
|
|||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt4"
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"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) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **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 | - |
|
||||
| **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) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
|
||||
| **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_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "ark-code-latest",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
|
|
@ -1002,7 +1011,7 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
|||
],
|
||||
"agents": {
|
||||
"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**
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**VolcEngine (Doubao)**
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
|
@ -1057,14 +1075,14 @@ Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-r
|
|||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api1.example.com/v1",
|
||||
"api_key": "sk-key1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api2.example.com/v1",
|
||||
"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 |
|
||||
| --- | --- | --- |
|
||||
| **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 |
|
||||
| **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) |
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||
</div>
|
||||
|
|
|
|||
59
README.vi.md
59
README.vi.md
|
|
@ -1,5 +1,5 @@
|
|||
<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>
|
||||
|
||||
|
|
@ -187,9 +187,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
|||
### 🚀 Bắt đầu nhanh
|
||||
|
||||
> [!TIP]
|
||||
> Thiết lập API key trong `~/.picoclaw/config.json`.
|
||||
> Lấy API key: [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.
|
||||
> 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).
|
||||
|
||||
**1. Khởi tạo**
|
||||
|
||||
|
|
@ -203,8 +201,13 @@ picoclaw onboard
|
|||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "ark-code-latest",
|
||||
"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",
|
||||
"request_timeout": 300,
|
||||
"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) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **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 | - |
|
||||
| **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) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
|
||||
| **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_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "ark-code-latest",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
|
|
@ -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": {
|
||||
"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**
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**VolcEngine (Doubao)**
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"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_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api1.example.com/v1",
|
||||
"api_key": "sk-key1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api2.example.com/v1",
|
||||
"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 |
|
||||
| --- | --- | --- |
|
||||
| **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 |
|
||||
| **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>
|
||||
|
|
|
|||
62
README.zh.md
62
README.zh.md
|
|
@ -1,5 +1,5 @@
|
|||
<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>
|
||||
|
||||
|
|
@ -208,9 +208,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
|||
### 🚀 快速开始
|
||||
|
||||
> [!TIP]
|
||||
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
|
||||
> 获取 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 次免费查询)
|
||||
> 在 `~/.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 次免费查询)。
|
||||
|
||||
**1. 初始化 (Initialize)**
|
||||
|
||||
|
|
@ -226,7 +224,7 @@ picoclaw onboard
|
|||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model_name": "gpt4",
|
||||
"model_name": "gpt-5.4",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
|
|
@ -234,8 +232,13 @@ picoclaw onboard
|
|||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "ark-code-latest",
|
||||
"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",
|
||||
"request_timeout": 300
|
||||
},
|
||||
|
|
@ -514,8 +517,9 @@ Agent 读取 HEARTBEAT.md
|
|||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
|
||||
| **火山引擎** | `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 | - |
|
||||
| **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) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
|
@ -526,8 +530,13 @@ Agent 读取 HEARTBEAT.md
|
|||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "ark-code-latest",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
|
|
@ -543,7 +552,7 @@ Agent 读取 HEARTBEAT.md
|
|||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "gpt-5.2"
|
||||
"model": "gpt-5.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -555,8 +564,18 @@ Agent 读取 HEARTBEAT.md
|
|||
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**火山引擎(Doubao)**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
|
@ -622,14 +641,14 @@ Agent 读取 HEARTBEAT.md
|
|||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api1.example.com/v1",
|
||||
"api_key": "sk-key1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api2.example.com/v1",
|
||||
"api_key": "sk-key2"
|
||||
}
|
||||
|
|
@ -875,8 +894,15 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
|||
| 服务 | 免费层级 | 适用场景 |
|
||||
| --- | --- | --- |
|
||||
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
||||
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
||||
| **火山引擎 CodingPlan** | 9.9 元/首月 | 最适合国内用户,多种 SOTA 模型(豆包、DeepSeek 等) |
|
||||
| **智谱 (Zhipu)** | 200K tokens/月 | 适合中国用户 |
|
||||
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
||||
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
|
||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
||||
| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) |
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||
</div>
|
||||
|
|
|
|||
BIN
assets/logo.webp
Normal file
BIN
assets/logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
|
|
@ -49,7 +49,7 @@ func (s *appState) modelMenu() tview.Primitive {
|
|||
Action: func() {
|
||||
newName := s.nextAvailableModelName("new-model")
|
||||
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),
|
||||
|
|
@ -291,7 +291,7 @@ func refreshModelMenuFromState(menu *Menu, s *appState) {
|
|||
Action: func() {
|
||||
newName := s.nextAvailableModelName("new-model")
|
||||
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))
|
||||
},
|
||||
|
|
|
|||
|
|
@ -72,14 +72,14 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
|||
// If no openai in ModelList, add it
|
||||
if !foundOpenAI {
|
||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return fmt.Errorf("could not update config: %w", err)
|
||||
|
|
@ -90,7 +90,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
|||
if 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
|
||||
}
|
||||
|
|
@ -318,13 +318,13 @@ func authLoginPasteToken(provider string) error {
|
|||
}
|
||||
if !found {
|
||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
AuthMethod: "token",
|
||||
})
|
||||
}
|
||||
// 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 {
|
||||
return fmt.Errorf("could not update config: %w", err)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true,
|
||||
"model_name": "gpt4",
|
||||
"model_name": "gpt-5.4",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20,
|
||||
|
|
@ -13,8 +13,8 @@
|
|||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-openai-key",
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
},
|
||||
|
|
@ -41,14 +41,14 @@
|
|||
"api_key": "your-longcat-api-key"
|
||||
},
|
||||
{
|
||||
"model_name": "loadbalanced-gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "loadbalanced-gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-key1",
|
||||
"api_base": "https://api1.example.com/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "loadbalanced-gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "loadbalanced-gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-key2",
|
||||
"api_base": "https://api2.example.com/v1"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
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
|
||||
|
||||
### 2.2 New Configuration Structure
|
||||
|
|
@ -81,8 +81,8 @@ Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
|
|||
"api_key": "sk-xxx"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-xxx"
|
||||
},
|
||||
{
|
||||
|
|
@ -128,7 +128,7 @@ type Config struct {
|
|||
type ModelConfig struct {
|
||||
// Required
|
||||
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
|
||||
APIBase string `json:"api_base,omitempty"`
|
||||
|
|
@ -180,7 +180,7 @@ Identify protocol via prefix in `model` field:
|
|||
"model": "deepseek-chat"
|
||||
},
|
||||
"coder": {
|
||||
"model": "gpt-5.2",
|
||||
"model": "gpt-5.4",
|
||||
"system_prompt": "You are a coding assistant..."
|
||||
},
|
||||
"translator": {
|
||||
|
|
@ -200,7 +200,7 @@ Each Agent only needs to specify `model` (corresponds to `model_name` in `model_
|
|||
model_list:
|
||||
- model_name: gpt-4o
|
||||
litellm_params:
|
||||
model: openai/gpt-5.2
|
||||
model: openai/gpt-5.4
|
||||
api_key: xxx
|
||||
- model_name: my-custom
|
||||
litellm_params:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ The new `model_list` configuration offers several advantages:
|
|||
"agents": {
|
||||
"defaults": {
|
||||
"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_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-openai-key",
|
||||
"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 |
|
||||
|--------|-------------|---------|
|
||||
| `openai/` | OpenAI API (default) | `openai/gpt-5.2` |
|
||||
| `openai/` | OpenAI API (default) | `openai/gpt-5.4` |
|
||||
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
|
||||
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
|
||||
| `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 |
|
||||
|-------|----------|-------------|
|
||||
| `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_key` | No* | API authentication key |
|
||||
| `proxy` | No | HTTP proxy URL |
|
||||
|
|
@ -130,19 +130,19 @@ Configure multiple endpoints for the same model to distribute load:
|
|||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-key1",
|
||||
"api_base": "https://api1.example.com/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-key2",
|
||||
"api_base": "https://api2.example.com/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-key3",
|
||||
"api_base": "https://api3.example.com/v1"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ const (
|
|||
lineBotInfoEndpoint = lineAPIBase + "/info"
|
||||
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
|
||||
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 {
|
||||
|
|
@ -166,7 +170,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
|
||||
if err != nil {
|
||||
logger.ErrorCF("line", "Failed to read request body", map[string]any{
|
||||
"error": err.Error(),
|
||||
|
|
@ -174,6 +178,11 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
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")
|
||||
if !c.verifySignature(body, signature) {
|
||||
|
|
|
|||
81
pkg/channels/line/line_test.go
Normal file
81
pkg/channels/line/line_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -127,7 +127,12 @@ func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) b
|
|||
// Implements PlaceholderRecorder.
|
||||
func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -616,6 +616,37 @@ func TestRecordTypingStop_ConcurrentSafe(t *testing.T) {
|
|||
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) {
|
||||
m := newTestManager()
|
||||
var sendCalled bool
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"mime"
|
||||
"net/url"
|
||||
"os"
|
||||
|
|
@ -726,17 +727,23 @@ func (c *MatrixChannel) downloadMedia(
|
|||
reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
data, err := c.client.DownloadBytes(reqCtx, parsed)
|
||||
resp, err := c.client.Download(reqCtx, parsed)
|
||||
if err != nil {
|
||||
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.
|
||||
if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" {
|
||||
err = msgEvt.File.DecryptInPlace(data)
|
||||
if err != nil {
|
||||
if err = msgEvt.File.PrepareForDecryption(); err != nil {
|
||||
return "", fmt.Errorf("decrypt matrix media: %w", err)
|
||||
}
|
||||
decryptReader := msgEvt.File.DecryptStream(resp.Body)
|
||||
reader = decryptReader
|
||||
readerClose = decryptReader.Close
|
||||
}
|
||||
|
||||
label := matrixMediaLabel(msgEvt, mediaKind)
|
||||
|
|
@ -749,14 +756,28 @@ func (c *MatrixChannel) downloadMedia(
|
|||
if err != nil {
|
||||
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 {
|
||||
_ = os.Remove(tmp.Name())
|
||||
_, err = io.Copy(tmp, reader)
|
||||
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 tmp.Name(), nil
|
||||
cleanup = false
|
||||
return tmpPath, nil
|
||||
}
|
||||
|
||||
func matrixContentType(msgEvt *event.MessageEventContent) string {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package matrix
|
|||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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) {
|
||||
ch := &MatrixChannel{}
|
||||
msg := &event.MessageEventContent{
|
||||
|
|
|
|||
|
|
@ -444,7 +444,7 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) {
|
|||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
configJSON := `{
|
||||
"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"}}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
|
||||
|
|
|
|||
|
|
@ -194,8 +194,8 @@ func DefaultConfig() *Config {
|
|||
|
||||
// OpenAI - https://platform.openai.com/api-keys
|
||||
{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
APIBase: "https://api.openai.com/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
|
@ -256,8 +256,8 @@ func DefaultConfig() *Config {
|
|||
APIKey: "",
|
||||
},
|
||||
{
|
||||
ModelName: "openrouter-gpt-5.2",
|
||||
Model: "openrouter/openai/gpt-5.2",
|
||||
ModelName: "openrouter-gpt-5.4",
|
||||
Model: "openrouter/openai/gpt-5.4",
|
||||
APIBase: "https://openrouter.ai/api/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
|
@ -287,6 +287,12 @@ func DefaultConfig() *Config {
|
|||
},
|
||||
|
||||
// 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",
|
||||
Model: "volcengine/doubao-pro-32k",
|
||||
|
|
@ -311,8 +317,8 @@ func DefaultConfig() *Config {
|
|||
|
||||
// GitHub Copilot - https://github.com/settings/tokens
|
||||
{
|
||||
ModelName: "copilot-gpt-5.2",
|
||||
Model: "github-copilot/gpt-5.2",
|
||||
ModelName: "copilot-gpt-5.4",
|
||||
Model: "github-copilot/gpt-5.4",
|
||||
APIBase: "http://localhost:4321",
|
||||
AuthMethod: "oauth",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
|||
}
|
||||
return ModelConfig{
|
||||
ModelName: "openai",
|
||||
Model: "openai/gpt-5.2",
|
||||
Model: "openai/gpt-5.4",
|
||||
APIKey: p.OpenAI.APIKey,
|
||||
APIBase: p.OpenAI.APIBase,
|
||||
Proxy: p.OpenAI.Proxy,
|
||||
|
|
@ -335,7 +335,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
|||
}
|
||||
return ModelConfig{
|
||||
ModelName: "github-copilot",
|
||||
Model: "github-copilot/gpt-5.2",
|
||||
Model: "github-copilot/gpt-5.4",
|
||||
APIBase: p.GitHubCopilot.APIBase,
|
||||
ConnectMode: p.GitHubCopilot.ConnectMode,
|
||||
}, true
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
|
|||
if result[0].ModelName != "openai" {
|
||||
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai")
|
||||
}
|
||||
if result[0].Model != "openai/gpt-5.2" {
|
||||
t.Errorf("Model = %q, want %q", 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.4")
|
||||
}
|
||||
if 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 {
|
||||
switch mc.ModelName {
|
||||
case "openai":
|
||||
if mc.Model != "openai/gpt-5.2" {
|
||||
t.Errorf("OpenAI Model = %q, want %q (default)", 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.4")
|
||||
}
|
||||
case "deepseek":
|
||||
if mc.Model != "deepseek/deepseek-reasoner" {
|
||||
|
|
@ -558,9 +558,9 @@ func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
|
|||
// Tests for buildModelWithProtocol helper function
|
||||
|
||||
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
|
||||
result := buildModelWithProtocol("openai", "gpt-5.2")
|
||||
if result != "openai/gpt-5.2" {
|
||||
t.Errorf("buildModelWithProtocol(openai, gpt-5.2) = %q, want %q", result, "openai/gpt-5.2")
|
||||
result := buildModelWithProtocol("openai", "gpt-5.4")
|
||||
if result != "openai/gpt-5.4" {
|
||||
t.Errorf("buildModelWithProtocol(openai, gpt-5.4) = %q, want %q", result, "openai/gpt-5.4")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -490,7 +490,7 @@ echo '{"type":"turn.completed"}'`
|
|||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
|
|
@ -502,7 +502,7 @@ echo '{"type":"turn.completed"}'`
|
|||
}
|
||||
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)
|
||||
}
|
||||
if !strings.Contains(args, "-C /tmp/test-workspace") {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
codexDefaultModel = "gpt-5.2"
|
||||
codexDefaultModel = "gpt-5.3-codex"
|
||||
codexDefaultInstructions = "You are Codex, a coding assistant."
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -568,7 +568,7 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T)
|
|||
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
|
||||
|
||||
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 {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
|
|
@ -599,7 +599,7 @@ func TestResolveCodexModel(t *testing.T) {
|
|||
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},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ func TestGatewayStartReady_OAuthModelRequiresStoredCredential(t *testing.T) {
|
|||
}
|
||||
cfg.ModelList = []config.ModelConfig{{
|
||||
ModelName: "openai-oauth",
|
||||
Model: "openai/gpt-5.2",
|
||||
Model: "openai/gpt-5.4",
|
||||
AuthMethod: "oauth",
|
||||
}}
|
||||
cfg.Agents.Defaults.ModelName = "openai-oauth"
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
|
|||
cfg.ModelList = []config.ModelConfig{
|
||||
{
|
||||
ModelName: "openai-oauth",
|
||||
Model: "openai/gpt-5.2",
|
||||
Model: "openai/gpt-5.4",
|
||||
AuthMethod: "oauth",
|
||||
},
|
||||
{
|
||||
|
|
@ -81,8 +81,8 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
|
|||
APIKey: "remote-key",
|
||||
},
|
||||
{
|
||||
ModelName: "copilot-gpt-5.2",
|
||||
Model: "github-copilot/gpt-5.2",
|
||||
ModelName: "copilot-gpt-5.4",
|
||||
Model: "github-copilot/gpt-5.4",
|
||||
APIBase: "http://127.0.0.1:4321",
|
||||
AuthMethod: "oauth",
|
||||
},
|
||||
|
|
@ -128,7 +128,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
|
|||
if !got["vllm-remote"] {
|
||||
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")
|
||||
}
|
||||
if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model" {
|
||||
|
|
|
|||
|
|
@ -791,8 +791,8 @@ func defaultModelConfigForProvider(provider, authMethod string) config.ModelConf
|
|||
switch provider {
|
||||
case oauthProviderOpenAI:
|
||||
return config.ModelConfig{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
AuthMethod: authMethod,
|
||||
}
|
||||
case oauthProviderAnthropic:
|
||||
|
|
|
|||
|
|
@ -168,8 +168,8 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
|
|||
}
|
||||
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
if err = config.SaveConfig(configPath, cfg); err != nil {
|
||||
|
|
|
|||
1
web/frontend/.gitignore
vendored
1
web/frontend/.gitignore
vendored
|
|
@ -1,5 +1,4 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@
|
|||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wrap-ansi": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
|
|
|||
28
web/frontend/pnpm-lock.yaml
generated
28
web/frontend/pnpm-lock.yaml
generated
|
|
@ -80,6 +80,9 @@ importers:
|
|||
tw-animate-css:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
wrap-ansi:
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.39.1
|
||||
|
|
@ -1837,6 +1840,10 @@ packages:
|
|||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-styles@6.2.3:
|
||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansis@4.2.0:
|
||||
resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
|
||||
engines: {node: '>=14'}
|
||||
|
|
@ -3558,6 +3565,10 @@ packages:
|
|||
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
string-width@8.2.0:
|
||||
resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||
|
||||
|
|
@ -3883,6 +3894,10 @@ packages:
|
|||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -5677,6 +5692,8 @@ snapshots:
|
|||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
ansi-styles@6.2.3: {}
|
||||
|
||||
ansis@4.2.0: {}
|
||||
|
||||
anymatch@3.1.3:
|
||||
|
|
@ -7628,6 +7645,11 @@ snapshots:
|
|||
get-east-asian-width: 1.5.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:
|
||||
dependencies:
|
||||
character-entities-html4: 2.1.0
|
||||
|
|
@ -7904,6 +7926,12 @@ snapshots:
|
|||
|
||||
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:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
setLauncherConfig as updateLauncherConfig,
|
||||
} from "@/api/system"
|
||||
import {
|
||||
AdvancedSection,
|
||||
AgentDefaultsSection,
|
||||
DevicesSection,
|
||||
LauncherSection,
|
||||
|
|
@ -30,7 +29,6 @@ import {
|
|||
} from "@/components/config/form-model"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -56,11 +54,7 @@ export function ConfigPage() {
|
|||
},
|
||||
})
|
||||
|
||||
const {
|
||||
data: launcherConfig,
|
||||
isLoading: isLauncherLoading,
|
||||
error: launcherError,
|
||||
} = useQuery({
|
||||
const { data: launcherConfig, isLoading: isLauncherLoading } = useQuery({
|
||||
queryKey: ["system", "launcher-config"],
|
||||
queryFn: getLauncherConfig,
|
||||
})
|
||||
|
|
@ -111,10 +105,6 @@ export function ConfigPage() {
|
|||
? t("pages.config.autostart_unsupported")
|
||||
: 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>(
|
||||
key: K,
|
||||
value: CoreConfigForm[K],
|
||||
|
|
@ -287,21 +277,14 @@ export function ConfigPage() {
|
|||
|
||||
<AgentDefaultsSection form={form} onFieldChange={updateField} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<RuntimeSection form={form} onFieldChange={updateField} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<LauncherSection
|
||||
launcherForm={launcherForm}
|
||||
onFieldChange={updateLauncherField}
|
||||
launcherHint={launcherHint}
|
||||
disabled={saving || isLauncherLoading}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DevicesSection
|
||||
form={form}
|
||||
onFieldChange={updateField}
|
||||
|
|
@ -316,10 +299,6 @@ export function ConfigPage() {
|
|||
onAutoStartChange={setAutoStartEnabled}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<AdvancedSection />
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { IconCode } from "@tabler/icons-react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import type { ReactNode } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
|
|
@ -8,7 +7,13 @@ import {
|
|||
type LauncherForm,
|
||||
} from "@/components/config/form-model"
|
||||
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 {
|
||||
Select,
|
||||
|
|
@ -29,6 +34,30 @@ type UpdateLauncherField = <K extends keyof LauncherForm>(
|
|||
value: LauncherForm[K],
|
||||
) => 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 {
|
||||
form: CoreConfigForm
|
||||
onFieldChange: UpdateCoreField
|
||||
|
|
@ -41,11 +70,11 @@ export function AgentDefaultsSection({
|
|||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<ConfigSectionCard title={t("pages.config.sections.agent")}>
|
||||
<Field
|
||||
label={t("pages.config.workspace")}
|
||||
hint={t("pages.config.workspace_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
value={form.workspace}
|
||||
|
|
@ -57,6 +86,7 @@ export function AgentDefaultsSection({
|
|||
<SwitchCardField
|
||||
label={t("pages.config.restrict_workspace")}
|
||||
hint={t("pages.config.restrict_workspace_hint")}
|
||||
layout="setting-row"
|
||||
checked={form.restrictToWorkspace}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("restrictToWorkspace", checked)
|
||||
|
|
@ -66,6 +96,7 @@ export function AgentDefaultsSection({
|
|||
<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)}
|
||||
/>
|
||||
|
|
@ -73,6 +104,7 @@ export function AgentDefaultsSection({
|
|||
<Field
|
||||
label={t("pages.config.max_tokens")}
|
||||
hint={t("pages.config.max_tokens_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
|
|
@ -85,6 +117,7 @@ export function AgentDefaultsSection({
|
|||
<Field
|
||||
label={t("pages.config.max_tool_iterations")}
|
||||
hint={t("pages.config.max_tool_iterations_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
|
|
@ -97,6 +130,7 @@ export function AgentDefaultsSection({
|
|||
<Field
|
||||
label={t("pages.config.summarize_threshold")}
|
||||
hint={t("pages.config.summarize_threshold_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
|
|
@ -111,6 +145,7 @@ export function AgentDefaultsSection({
|
|||
<Field
|
||||
label={t("pages.config.summarize_token_percent")}
|
||||
hint={t("pages.config.summarize_token_percent_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
|
|
@ -122,8 +157,7 @@ export function AgentDefaultsSection({
|
|||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
</ConfigSectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -139,17 +173,17 @@ export function RuntimeSection({ form, onFieldChange }: RuntimeSectionProps) {
|
|||
)
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<ConfigSectionCard title={t("pages.config.sections.runtime")}>
|
||||
<Field
|
||||
label={t("pages.config.session_scope")}
|
||||
hint={t("pages.config.session_scope_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Select
|
||||
value={form.dmScope}
|
||||
onValueChange={(value) => onFieldChange("dmScope", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
{selectedDmScopeOption
|
||||
? t(
|
||||
|
|
@ -177,6 +211,7 @@ export function RuntimeSection({ form, onFieldChange }: RuntimeSectionProps) {
|
|||
<SwitchCardField
|
||||
label={t("pages.config.heartbeat_enabled")}
|
||||
hint={t("pages.config.heartbeat_enabled_hint")}
|
||||
layout="setting-row"
|
||||
checked={form.heartbeatEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("heartbeatEnabled", checked)
|
||||
|
|
@ -187,43 +222,48 @@ export function RuntimeSection({ form, onFieldChange }: RuntimeSectionProps) {
|
|||
<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)
|
||||
}
|
||||
onChange={(e) => onFieldChange("heartbeatInterval", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</ConfigSectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
interface LauncherSectionProps {
|
||||
launcherForm: LauncherForm
|
||||
onFieldChange: UpdateLauncherField
|
||||
launcherHint: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export function LauncherSection({
|
||||
launcherForm,
|
||||
onFieldChange,
|
||||
launcherHint,
|
||||
disabled,
|
||||
}: LauncherSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<ConfigSectionCard title={t("pages.config.sections.launcher")}>
|
||||
<SwitchCardField
|
||||
label={t("pages.config.lan_access")}
|
||||
hint={t("pages.config.lan_access_hint")}
|
||||
layout="setting-row"
|
||||
checked={launcherForm.publicAccess}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => onFieldChange("publicAccess", checked)}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.server_port")}
|
||||
hint={t("pages.config.server_port_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
|
|
@ -235,17 +275,11 @@ export function LauncherSection({
|
|||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.lan_access")}
|
||||
hint={t("pages.config.lan_access_hint")}
|
||||
checked={launcherForm.publicAccess}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => onFieldChange("publicAccess", checked)}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.allowed_cidrs")}
|
||||
hint={t("pages.config.allowed_cidrs_hint")}
|
||||
layout="setting-row"
|
||||
controlClassName="md:max-w-md"
|
||||
>
|
||||
<Textarea
|
||||
value={launcherForm.allowedCIDRsText}
|
||||
|
|
@ -255,10 +289,7 @@ export function LauncherSection({
|
|||
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<p className="text-muted-foreground text-xs">{launcherHint}</p>
|
||||
</div>
|
||||
</section>
|
||||
</ConfigSectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -282,20 +313,19 @@ export function DevicesSection({
|
|||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<ConfigSectionCard title={t("pages.config.sections.devices")}>
|
||||
<SwitchCardField
|
||||
label={t("pages.config.devices_enabled")}
|
||||
hint={t("pages.config.devices_enabled_hint")}
|
||||
layout="setting-row"
|
||||
checked={form.devicesEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("devicesEnabled", checked)
|
||||
}
|
||||
onCheckedChange={(checked) => onFieldChange("devicesEnabled", checked)}
|
||||
/>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.monitor_usb")}
|
||||
hint={t("pages.config.monitor_usb_hint")}
|
||||
layout="setting-row"
|
||||
checked={form.monitorUSB}
|
||||
onCheckedChange={(checked) => onFieldChange("monitorUSB", checked)}
|
||||
/>
|
||||
|
|
@ -303,31 +333,11 @@ export function DevicesSection({
|
|||
<SwitchCardField
|
||||
label={t("pages.config.autostart_label")}
|
||||
hint={autoStartHint}
|
||||
layout="setting-row"
|
||||
checked={autoStartEnabled}
|
||||
disabled={autoStartDisabled}
|
||||
onCheckedChange={onAutoStartChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
</ConfigSectionCard>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { IconAdjustments } from "@tabler/icons-react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -15,17 +18,9 @@ import {
|
|||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
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"
|
||||
|
||||
export function RawJsonPanel() {
|
||||
export function RawConfigPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
|
|
@ -124,37 +119,42 @@ export function RawJsonPanel() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("pages.config.raw_json_title")}</CardTitle>
|
||||
<CardDescription>{t("pages.config.raw_json_desc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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 min-h-0 flex-1 flex-col p-1 lg:p-3 lg:p-6">
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-[1000px] flex-col">
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p>{t("labels.loading")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
{isDirty && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
|
||||
<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="bg-muted/30 relative rounded-lg border">
|
||||
<ScrollArea className="h-[calc(100vh-20rem)] min-h-[200px]">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg border shadow-sm">
|
||||
<Textarea
|
||||
value={effectiveEditorValue}
|
||||
onChange={(e) => {
|
||||
setEditorValue(e.target.value)
|
||||
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")}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<div className="flex shrink-0 justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFormat}
|
||||
|
|
@ -185,7 +185,9 @@ export function RawJsonPanel() {
|
|||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmReset}>
|
||||
{t("common.confirm")}
|
||||
</AlertDialogAction>
|
||||
|
|
@ -198,7 +200,8 @@ export function RawJsonPanel() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
web/frontend/src/components/logs/ansi-log-line.tsx
Normal file
24
web/frontend/src/components/logs/ansi-log-line.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
web/frontend/src/components/logs/logs-page.tsx
Normal file
42
web/frontend/src/components/logs/logs-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
web/frontend/src/components/logs/logs-panel.tsx
Normal file
55
web/frontend/src/components/logs/logs-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,6 +9,9 @@ import {
|
|||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type FieldLayout = "default" | "setting-row"
|
||||
|
||||
interface FieldProps {
|
||||
label: string
|
||||
|
|
@ -16,9 +19,45 @@ interface FieldProps {
|
|||
error?: string
|
||||
required?: boolean
|
||||
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 (
|
||||
<UiField className="gap-2.5">
|
||||
<div className="space-y-1">
|
||||
|
|
@ -85,6 +124,7 @@ interface SwitchCardFieldProps {
|
|||
ariaLabel?: string
|
||||
disabled?: boolean
|
||||
children?: ReactNode
|
||||
layout?: FieldLayout
|
||||
}
|
||||
|
||||
export function SwitchCardField({
|
||||
|
|
@ -96,7 +136,37 @@ export function SwitchCardField({
|
|||
ariaLabel,
|
||||
disabled,
|
||||
children,
|
||||
layout = "default",
|
||||
}: 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 (
|
||||
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
|
|
|
|||
96
web/frontend/src/hooks/use-gateway-logs.ts
Normal file
96
web/frontend/src/hooks/use-gateway-logs.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
52
web/frontend/src/hooks/use-log-wrap-columns.ts
Normal file
52
web/frontend/src/hooks/use-log-wrap-columns.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -331,33 +331,10 @@
|
|||
"pages": {
|
||||
"agent": {
|
||||
"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": {
|
||||
"title": "Skills",
|
||||
"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.",
|
||||
"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_error": "Failed to import skill.",
|
||||
"view": "View",
|
||||
|
|
@ -371,28 +348,11 @@
|
|||
"viewer_description": "Read the current effective SKILL.md content here.",
|
||||
"loading_detail": "Loading skill content...",
|
||||
"load_detail_error": "Failed to load skill content.",
|
||||
"source": "Source",
|
||||
"path": "Skill Path",
|
||||
"no_description": "No description provided.",
|
||||
"sources": {
|
||||
"workspace": "Workspace",
|
||||
"global": "Global",
|
||||
"builtin": "Builtin"
|
||||
},
|
||||
"errors": {
|
||||
"file_required": "Please choose a markdown file to import."
|
||||
}
|
||||
"no_description": "No description provided."
|
||||
},
|
||||
"tools": {
|
||||
"title": "Tools",
|
||||
"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.",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
|
|
@ -468,13 +428,15 @@
|
|||
"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_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||
"launcher_load_error": "Failed to load service parameters.",
|
||||
"launcher_restart_hint": "Service parameter changes apply after restarting PicoClaw Web.",
|
||||
"advanced_desc": "Open the raw JSON page to edit every field directly.",
|
||||
"sections": {
|
||||
"agent": "Agent",
|
||||
"runtime": "Runtime",
|
||||
"launcher": "Service",
|
||||
"devices": "Devices"
|
||||
},
|
||||
"open_raw": "Raw Config",
|
||||
"back_to_visual": "Visual Config",
|
||||
"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...",
|
||||
"save_success": "Configuration saved successfully.",
|
||||
"save_error": "Failed to save configuration.",
|
||||
|
|
@ -488,7 +450,6 @@
|
|||
"unsaved_changes": "You have unsaved changes."
|
||||
},
|
||||
"logs": {
|
||||
"description": "System logs and monitoring.",
|
||||
"clear": "Clear logs",
|
||||
"empty": "Waiting for logs..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -331,33 +331,10 @@
|
|||
"pages": {
|
||||
"agent": {
|
||||
"load_error": "加载 Agent 支持信息失败。",
|
||||
"stats": {
|
||||
"workspace": "工作目录",
|
||||
"workspace_hint": "默认 Agent 运行时使用的工作目录,也用于加载工作区技能。",
|
||||
"skills": "可用技能数",
|
||||
"skills_hint": "从工作区、全局目录和内置目录发现的技能。",
|
||||
"tools": "已启用工具",
|
||||
"tools_hint": "其中 {{blocked}} 个因依赖未满足而不可用。"
|
||||
},
|
||||
"skills": {
|
||||
"title": "技能",
|
||||
"description": "技能会从工作区、PicoClaw 全局目录和内置目录中加载。",
|
||||
"hero_title": "技能库",
|
||||
"hero_description": "在这里查看 Agent 当前可加载的能力包,并且不离开页面就能直接阅读生效后的 SKILL.md。",
|
||||
"stats": {
|
||||
"total": "技能总数",
|
||||
"workspace": "工作区技能",
|
||||
"shared": "共享技能"
|
||||
},
|
||||
"empty": "当前没有可用技能。",
|
||||
"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_error": "导入技能失败。",
|
||||
"view": "查看",
|
||||
|
|
@ -371,28 +348,11 @@
|
|||
"viewer_description": "这里展示当前生效的 SKILL.md 内容。",
|
||||
"loading_detail": "正在加载技能内容...",
|
||||
"load_detail_error": "加载技能内容失败。",
|
||||
"source": "来源",
|
||||
"path": "技能路径",
|
||||
"no_description": "未提供描述。",
|
||||
"sources": {
|
||||
"workspace": "工作区",
|
||||
"global": "全局",
|
||||
"builtin": "内置"
|
||||
},
|
||||
"errors": {
|
||||
"file_required": "请先选择要导入的 Markdown 文件。"
|
||||
}
|
||||
"no_description": "未提供描述。"
|
||||
},
|
||||
"tools": {
|
||||
"title": "工具",
|
||||
"description": "这里展示每个 Agent 工具当前是已启用、已禁用,还是被依赖条件阻塞。",
|
||||
"hero_title": "工具面板",
|
||||
"hero_description": "集中查看 Agent 现在真正可调用的工具、被阻塞的能力,以及它们分别受哪项配置控制。",
|
||||
"stats": {
|
||||
"enabled": "已启用",
|
||||
"blocked": "被阻塞",
|
||||
"categories": "分类数"
|
||||
},
|
||||
"empty": "当前没有可用工具。",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
|
|
@ -465,16 +425,18 @@
|
|||
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口。",
|
||||
"lan_access": "启用局域网访问",
|
||||
"lan_access_hint": "允许局域网中的其他设备访问当前服务。",
|
||||
"allowed_cidrs": "允许访问网段(CIDR)",
|
||||
"allowed_cidrs": "允许访问网段",
|
||||
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。",
|
||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||
"launcher_load_error": "加载服务参数失败。",
|
||||
"launcher_restart_hint": "服务参数变更需重启 PicoClaw Web 后生效。",
|
||||
"advanced_desc": "可打开原始 JSON 页面直接编辑全部字段。",
|
||||
"sections": {
|
||||
"agent": "智能体",
|
||||
"runtime": "运行时",
|
||||
"launcher": "服务参数",
|
||||
"devices": "设备"
|
||||
},
|
||||
"open_raw": "原始配置",
|
||||
"back_to_visual": "可视化配置",
|
||||
"raw_json_title": "原始 JSON 配置",
|
||||
"raw_json_desc": "高级用户可以直接编辑下方的原始 JSON 配置。",
|
||||
"json_placeholder": "请输入有效的 JSON 配置...",
|
||||
"save_success": "配置保存成功。",
|
||||
"save_error": "配置保存失败。",
|
||||
|
|
@ -488,7 +450,6 @@
|
|||
"unsaved_changes": "您有未保存的更改。"
|
||||
},
|
||||
"logs": {
|
||||
"description": "系统日志和监控。",
|
||||
"clear": "清空日志",
|
||||
"empty": "等待日志中..."
|
||||
}
|
||||
|
|
|
|||
290
web/frontend/src/lib/ansi-log.ts
Normal file
290
web/frontend/src/lib/ansi-log.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,34 +1,7 @@
|
|||
import { IconAdjustments } from "@tabler/icons-react"
|
||||
import { Link, createFileRoute } from "@tanstack/react-router"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import { RawJsonPanel } from "@/components/config/raw-json-panel"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { RawConfigPage } from "@/components/config/raw-config-page"
|
||||
|
||||
export const Route = createFileRoute("/config/raw")({
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,156 +1,7 @@
|
|||
import { IconTrash } from "@tabler/icons-react"
|
||||
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 { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { gatewayAtom } from "@/store/gateway"
|
||||
import { LogsPage } from "@/components/logs/logs-page"
|
||||
|
||||
export const Route = createFileRoute("/logs")({
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ Default model is `google/gemini-3-flash-preview` if none is set.
|
|||
Optional config file: `~/.summarize/config.json`
|
||||
|
||||
```json
|
||||
{ "model": "openai/gpt-5.2" }
|
||||
{ "model": "openai/gpt-5.4" }
|
||||
```
|
||||
|
||||
Optional services:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue