Merge branch 'sipeed:main' into main
This commit is contained in:
commit
b11f589c12
49 changed files with 1572 additions and 891 deletions
162
.github/workflows/nightly.yml
vendored
162
.github/workflows/nightly.yml
vendored
|
|
@ -9,64 +9,37 @@ permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-tag:
|
nightly:
|
||||||
name: Create Git Tag
|
name: Nightly Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
outputs:
|
packages: write
|
||||||
version: ${{ steps.version.outputs.version }}
|
|
||||||
tag: ${{ steps.version.outputs.tag }}
|
|
||||||
changelog: ${{ steps.version.outputs.changelog }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Generate and push tag
|
- name: Compute version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
DATE=$(date -u +%Y%m%d)
|
DATE=$(date -u +%Y%m%d)
|
||||||
SHA=$(git rev-parse --short=8 HEAD)
|
SHA=$(git rev-parse --short=8 HEAD)
|
||||||
BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true)
|
BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true)
|
||||||
if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then
|
if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then
|
||||||
TAG="v0.0.0-nightly.${DATE}.${SHA}"
|
VERSION="v0.0.0-nightly.${DATE}.${SHA}"
|
||||||
else
|
else
|
||||||
TAG="${BASE_VERSION}-nightly.${DATE}.${SHA}"
|
VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}"
|
||||||
fi
|
fi
|
||||||
VERSION=$TAG
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
|
||||||
echo "Tag $TAG already exists, reusing existing tag"
|
|
||||||
else
|
|
||||||
git tag -a "$TAG" -m "Nightly build $VERSION"
|
|
||||||
fi
|
|
||||||
git push origin "$TAG"
|
|
||||||
|
|
||||||
COMPARE_URL="https://github.com/${{ github.repository }}/commits/${TAG}"
|
|
||||||
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then
|
|
||||||
COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...${TAG}"
|
|
||||||
fi
|
|
||||||
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
release:
|
COMPARE_URL="https://github.com/${{ github.repository }}/commits/main"
|
||||||
name: GoReleaser Release
|
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then
|
||||||
needs: create-tag
|
COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...main"
|
||||||
runs-on: ubuntu-latest
|
fi
|
||||||
permissions:
|
|
||||||
contents: write
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
packages: write
|
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
|
||||||
steps:
|
|
||||||
- name: Checkout tag
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ needs.create-tag.outputs.tag }}
|
|
||||||
|
|
||||||
- name: Setup Go from go.mod
|
- name: Setup Go from go.mod
|
||||||
id: setup-go
|
id: setup-go
|
||||||
|
|
@ -95,6 +68,16 @@ jobs:
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: docker.io
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create local tag for GoReleaser
|
||||||
|
run: git tag "${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -106,6 +89,7 @@ jobs:
|
||||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||||
|
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
|
||||||
NIGHTLY_BUILD: "true"
|
NIGHTLY_BUILD: "true"
|
||||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||||
|
|
@ -113,92 +97,42 @@ jobs:
|
||||||
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||||
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||||
|
|
||||||
update-rolling:
|
|
||||||
name: Update Rolling Nightly
|
|
||||||
needs: [create-tag, release]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Update nightly release
|
- name: Update nightly release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG: ${{ needs.create-tag.outputs.tag }}
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
TITLE: ${{ needs.create-tag.outputs.version }}
|
|
||||||
run: |
|
run: |
|
||||||
CHANGELOG='${{ needs.create-tag.outputs.changelog }}'
|
CHANGELOG='${{ steps.version.outputs.changelog }}'
|
||||||
NOTES=$(cat <<EOF
|
NOTES=$(cat <<EOF
|
||||||
Nightly build for **${TITLE}**
|
Nightly build for **${VERSION}**
|
||||||
|
|
||||||
This is an automated build and may be unstable. Use with caution.
|
This is an automated build and may be unstable. Use with caution.
|
||||||
|
|
||||||
${CHANGELOG}
|
${CHANGELOG}
|
||||||
EOF
|
EOF
|
||||||
)
|
)
|
||||||
|
|
||||||
# Download assets from the newly created release if it exists,
|
# Delete existing nightly release and tag
|
||||||
# otherwise fall back to using locally built dist/ artifacts.
|
gh release delete nightly --cleanup-tag -y 2>/dev/null || true
|
||||||
mkdir -p build
|
|
||||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
# Force-update nightly tag to current HEAD
|
||||||
echo "Downloading assets from GitHub release for $TAG..."
|
git config user.name "github-actions[bot]"
|
||||||
gh release download "$TAG" --dir build
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
else
|
git tag -fa nightly -m "Nightly build ${VERSION}"
|
||||||
echo "GitHub release for $TAG not found; falling back to local dist/ artifacts..."
|
git push origin nightly
|
||||||
if [ -d "dist" ]; then
|
|
||||||
cp -R dist/* build/
|
# Collect release artifacts from goreleaser dist/
|
||||||
else
|
ASSETS=()
|
||||||
echo "Error: no GitHub release for $TAG and no local dist/ directory found." >&2
|
for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do
|
||||||
exit 1
|
[ -f "$f" ] && ASSETS+=("$f")
|
||||||
fi
|
done
|
||||||
fi
|
|
||||||
|
# Create nightly release (prerelease, NOT latest)
|
||||||
# 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
|
|
||||||
|
|
||||||
gh release create nightly \
|
gh release create nightly \
|
||||||
--title "Nightly Build" \
|
--title "Nightly Build" \
|
||||||
--notes "$NOTES" \
|
--notes "$NOTES" \
|
||||||
--target "${{ github.sha }}" \
|
--target "${{ github.sha }}" \
|
||||||
--prerelease \
|
--prerelease \
|
||||||
build/*
|
--latest=false \
|
||||||
|
"${ASSETS[@]}"
|
||||||
|
|
||||||
echo "Cleaning up old nightly releases (keeping only the most recent)..."
|
|
||||||
gh release list --limit 100 --json tagName -q '.[].tagName | select(contains("-nightly."))' | tail -n +2 | while read -r old_tag; do
|
|
||||||
if [ -n "$old_tag" ] && [ "$old_tag" != "$TAG" ]; then
|
|
||||||
echo "Deleting old nightly release: $old_tag"
|
|
||||||
gh release delete "$old_tag" --cleanup-tag -y || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Cleaning up old 'vX.X.X-nightly...' Docker images on GHCR..."
|
|
||||||
OWNER="${{ github.repository_owner }}"
|
|
||||||
PACKAGE_NAME="${{ github.event.repository.name }}"
|
|
||||||
|
|
||||||
# Check if owner is an organization or user
|
|
||||||
ORG_TEST=$(gh api -H "Accept: application/vnd.github+json" /orgs/$OWNER 2>/dev/null || true)
|
|
||||||
if echo "$ORG_TEST" | grep -q '"login"'; then
|
|
||||||
ACCOUNT_TYPE="orgs"
|
|
||||||
else
|
|
||||||
ACCOUNT_TYPE="users"
|
|
||||||
fi
|
|
||||||
|
|
||||||
PACKAGE_URL="/${ACCOUNT_TYPE}/${OWNER}/packages/container/${PACKAGE_NAME}/versions"
|
|
||||||
OLD_NIGHTLY_VERSIONS=$(gh api --paginate -H "Accept: application/vnd.github+json" \
|
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
||||||
"$PACKAGE_URL" \
|
|
||||||
--jq ". | map(select(any(.metadata.container.tags[]; contains(\"-nightly.\") and (. != \"nightly\") and (. != \"$TAG\")))) | .[].id" 2>/dev/null || true)
|
|
||||||
|
|
||||||
for version_id in $OLD_NIGHTLY_VERSIONS; do
|
|
||||||
if [ -n "$version_id" ]; then
|
|
||||||
echo "Deleting Docker image version ID: $version_id"
|
|
||||||
gh api -X DELETE -H "Accept: application/vnd.github+json" \
|
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
||||||
"/${ACCOUNT_TYPE}/${OWNER}/packages/container/${PACKAGE_NAME}/versions/$version_id" || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
|
||||||
|
|
@ -116,9 +116,9 @@ dockers_v2:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
images:
|
images:
|
||||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||||
- '{{ if not (isEnvSet "NIGHTLY_BUILD") }}docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}{{ end }}'
|
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
|
||||||
tags:
|
tags:
|
||||||
- "{{ .Tag }}"
|
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}'
|
||||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
|
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
|
||||||
platforms:
|
platforms:
|
||||||
- linux/amd64
|
- linux/amd64
|
||||||
|
|
@ -133,9 +133,9 @@ dockers_v2:
|
||||||
- picoclaw-launcher-tui
|
- picoclaw-launcher-tui
|
||||||
images:
|
images:
|
||||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||||
- '{{ if not (isEnvSet "NIGHTLY_BUILD") }}docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}{{ end }}'
|
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
|
||||||
tags:
|
tags:
|
||||||
- "{{ .Tag }}-launcher"
|
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}'
|
||||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}'
|
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}'
|
||||||
platforms:
|
platforms:
|
||||||
- linux/amd64
|
- linux/amd64
|
||||||
|
|
@ -215,6 +215,7 @@ changelog:
|
||||||
# lzma: true
|
# lzma: true
|
||||||
|
|
||||||
release:
|
release:
|
||||||
|
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
|
||||||
footer: >-
|
footer: >-
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
61
README.fr.md
61
README.fr.md
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1>
|
<h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1>
|
||||||
|
|
||||||
|
|
@ -206,9 +206,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
### 🚀 Démarrage Rapide
|
### 🚀 Démarrage Rapide
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Configurez votre clé API dans `~/.picoclaw/config.json`.
|
> Configurez votre clé API dans `~/.picoclaw/config.json`. Obtenez des clés API : [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). La recherche web est optionnelle — obtenez gratuitement l'[API Tavily](https://tavily.com) (1000 requêtes gratuites/mois) ou l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois).
|
||||||
> Obtenir des clés API : [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> La recherche web est **optionnelle** — obtenez gratuitement l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois) ou utilisez le repli automatique intégré.
|
|
||||||
|
|
||||||
**1. Initialiser**
|
**1. Initialiser**
|
||||||
|
|
||||||
|
|
@ -222,8 +220,13 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
"request_timeout": 300,
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
|
|
@ -231,7 +234,7 @@ picoclaw onboard
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model_name": "gpt4"
|
"model_name": "gpt-5.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
|
|
@ -977,8 +980,9 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) |
|
||||||
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
|
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
|
||||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
|
||||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obtenir une clé](https://longcat.chat/platform) |
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obtenir une clé](https://longcat.chat/platform) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
@ -989,8 +993,13 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1006,7 +1015,7 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1017,8 +1026,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VolcEngine (Doubao)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -1061,14 +1079,14 @@ Configurez plusieurs points de terminaison pour le même nom de modèle—PicoCl
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -1200,6 +1218,13 @@ Cela se produit lorsqu'une autre instance du bot est en cours d'exécution. Assu
|
||||||
| Service | Offre Gratuite | Cas d'Utilisation |
|
| Service | Offre Gratuite | Cas d'Utilisation |
|
||||||
| ---------------- | -------------------- | ------------------------------------- |
|
| ---------------- | -------------------- | ------------------------------------- |
|
||||||
| **OpenRouter** | 200K tokens/mois | Multiples modèles (Claude, GPT-4, etc.) |
|
| **OpenRouter** | 200K tokens/mois | Multiples modèles (Claude, GPT-4, etc.) |
|
||||||
| **Zhipu** | 200K tokens/mois | Idéal pour les utilisateurs chinois |
|
| **Volcengine CodingPlan** | 9,9¥/premier mois | Idéal pour les utilisateurs chinois, multiples modèles SOTA (Doubao, DeepSeek, etc.) |
|
||||||
|
| **Zhipu** | 200K tokens/mois | Convient aux utilisateurs chinois |
|
||||||
| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web |
|
| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web |
|
||||||
| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) |
|
| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
61
README.ja.md
61
README.ja.md
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
|
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
|
||||||
|
|
||||||
|
|
@ -168,9 +168,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
### 🚀 クイックスタート(ネイティブ)
|
### 🚀 クイックスタート(ネイティブ)
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> `~/.picoclaw/config.json` に API キーを設定してください。
|
> `~/.picoclaw/config.json` に API キーを設定してください。API キーの取得先: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。Web 検索は **任意** です — 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)。
|
||||||
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> Web 検索は **任意** です - 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
|
|
||||||
|
|
||||||
**1. 初期化**
|
**1. 初期化**
|
||||||
|
|
||||||
|
|
@ -184,8 +182,13 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
"request_timeout": 300,
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
|
|
@ -193,7 +196,7 @@ picoclaw onboard
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model_name": "gpt4"
|
"model_name": "gpt-5.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
|
|
@ -918,8 +921,9 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
|
||||||
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
|
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
|
||||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
|
||||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [キーを取得](https://longcat.chat/platform) |
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [キーを取得](https://longcat.chat/platform) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
|
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
@ -930,8 +934,13 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -947,7 +956,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -958,8 +967,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VolcEngine (Doubao)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -1002,14 +1020,14 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -1120,9 +1138,16 @@ Web 検索を有効にするには:
|
||||||
| サービス | 無料枠 | ユースケース |
|
| サービス | 無料枠 | ユースケース |
|
||||||
|---------|--------|------------|
|
|---------|--------|------------|
|
||||||
| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) |
|
| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) |
|
||||||
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
| **Volcengine CodingPlan** | 9.9元/初月 | 中国ユーザーに最適、複数のSOTAモデル(Doubao、DeepSeek等) |
|
||||||
|
| **Zhipu** | 月 200K トークン | 中国ユーザーに適している |
|
||||||
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
|
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
|
||||||
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
||||||
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
|
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
|
||||||
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
||||||
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
62
README.md
62
README.md
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
|
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
|
||||||
|
|
||||||
|
|
@ -227,9 +227,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
### 🚀 Quick Start
|
### 🚀 Quick Start
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Set your API key in `~/.picoclaw/config.json`.
|
> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month).
|
||||||
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month), [SearXNG](https://github.com/searxng/searxng) (free, self-hosted) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
|
||||||
|
|
||||||
**1. Initialize**
|
**1. Initialize**
|
||||||
|
|
||||||
|
|
@ -244,7 +242,7 @@ picoclaw onboard
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt-5.4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20
|
||||||
|
|
@ -252,8 +250,13 @@ picoclaw onboard
|
||||||
},
|
},
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "your-api-key",
|
"api_key": "your-api-key",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
|
|
@ -1030,8 +1033,9 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
||||||
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
|
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
|
||||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
|
||||||
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
|
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
|
||||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
||||||
|
|
@ -1043,8 +1047,13 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1060,7 +1069,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1072,8 +1081,18 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VolcEngine (Doubao)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -1152,14 +1171,14 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -1499,9 +1518,16 @@ This happens when another instance of the bot is running. Make sure only one `pi
|
||||||
| Service | Free Tier | Use Case |
|
| Service | Free Tier | Use Case |
|
||||||
| ---------------- | ------------------------ | ------------------------------------- |
|
| ---------------- | ------------------------ | ------------------------------------- |
|
||||||
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
||||||
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) |
|
||||||
|
| **Zhipu** | 200K tokens/month | Suitable for Chinese users |
|
||||||
| **Brave Search** | Paid ($5/1000 queries) | Web search functionality |
|
| **Brave Search** | Paid ($5/1000 queries) | Web search functionality |
|
||||||
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
|
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
|
||||||
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
||||||
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
||||||
| **LongCat** | Up to 5M tokens/day | Fast inference (free tier) |
|
| **LongCat** | Up to 5M tokens/day | Fast inference (free tier) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>
|
<h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>
|
||||||
|
|
||||||
|
|
@ -207,9 +207,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
### 🚀 Início Rápido
|
### 🚀 Início Rápido
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Configure sua API key em `~/.picoclaw/config.json`.
|
> Configure sua API key em `~/.picoclaw/config.json`. Obtenha API keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Busca web é **opcional** — obtenha a [API Tavily](https://tavily.com) gratuita (1000 consultas grátis/mês) ou a [Brave Search API](https://brave.com/search/api) (2000 consultas grátis/mês).
|
||||||
> Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas grátis/mês) ou use o fallback automático integrado.
|
|
||||||
|
|
||||||
**1. Inicializar**
|
**1. Inicializar**
|
||||||
|
|
||||||
|
|
@ -223,8 +221,13 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
"request_timeout": 300,
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
|
|
@ -232,7 +235,7 @@ picoclaw onboard
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model_name": "gpt4"
|
"model_name": "gpt-5.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
@ -973,8 +976,9 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) |
|
||||||
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
|
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
|
||||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
|
||||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obter Chave](https://longcat.chat/platform) |
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obter Chave](https://longcat.chat/platform) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
@ -985,8 +989,13 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1002,7 +1011,7 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1013,8 +1022,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VolcEngine (Doubao)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -1057,14 +1075,14 @@ Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-r
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -1196,7 +1214,14 @@ Isso acontece quando outra instância do bot está em execução. Certifique-se
|
||||||
| Serviço | Plano Gratuito | Caso de Uso |
|
| Serviço | Plano Gratuito | Caso de Uso |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) |
|
| **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) |
|
||||||
| **Zhipu** | 200K tokens/mês | Melhor para usuários chineses |
|
| **Volcengine CodingPlan** | ¥9,9/primeiro mês | Ideal para usuários chineses, múltiplos modelos SOTA (Doubao, DeepSeek, etc.) |
|
||||||
|
| **Zhipu** | 200K tokens/mês | Adequado para usuários chineses |
|
||||||
| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web |
|
| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web |
|
||||||
| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) |
|
| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) |
|
||||||
| **Cerebras** | Plano gratuito disponível | Inferência ultra-rápida (Llama 3.3 70B) |
|
| **Cerebras** | Plano gratuito disponível | Inferência ultra-rápida (Llama 3.3 70B) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
59
README.vi.md
59
README.vi.md
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
|
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
|
||||||
|
|
||||||
|
|
@ -187,9 +187,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
### 🚀 Bắt đầu nhanh
|
### 🚀 Bắt đầu nhanh
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Thiết lập API key trong `~/.picoclaw/config.json`.
|
> Thiết lập API key trong `~/.picoclaw/config.json`. Lấy API key: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Tìm kiếm web là **tùy chọn** — lấy [Tavily API](https://tavily.com) miễn phí (1000 truy vấn/tháng) hoặc [Brave Search API](https://brave.com/search/api) (2000 truy vấn/tháng).
|
||||||
> Lấy API key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> Tìm kiếm web là **tùy chọn** — lấy [Brave Search API](https://brave.com/search/api) miễn phí (2000 truy vấn/tháng) hoặc dùng tính năng auto fallback tích hợp sẵn.
|
|
||||||
|
|
||||||
**1. Khởi tạo**
|
**1. Khởi tạo**
|
||||||
|
|
||||||
|
|
@ -203,8 +201,13 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
"request_timeout": 300,
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
|
|
@ -942,8 +945,9 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) |
|
||||||
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
|
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
|
||||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
|
||||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Lấy Key](https://longcat.chat/platform) |
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Lấy Key](https://longcat.chat/platform) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
|
| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
@ -954,8 +958,13 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -971,7 +980,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -982,8 +991,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VolcEngine (Doubao)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -1026,14 +1044,14 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -1165,6 +1183,13 @@ Một số nhà cung cấp (như Zhipu) có bộ lọc nội dung nghiêm ngặt
|
||||||
| Dịch vụ | Gói miễn phí | Trường hợp sử dụng |
|
| Dịch vụ | Gói miễn phí | Trường hợp sử dụng |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) |
|
| **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) |
|
||||||
| **Zhipu** | 200K tokens/tháng | Tốt nhất cho người dùng Trung Quốc |
|
| **Volcengine CodingPlan** | ¥9.9/tháng đầu | Tốt nhất cho người dùng Trung Quốc, nhiều mô hình SOTA (Doubao, DeepSeek, v.v.) |
|
||||||
|
| **Zhipu** | 200K tokens/tháng | Phù hợp cho người dùng Trung Quốc |
|
||||||
| **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web |
|
| **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web |
|
||||||
| **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) |
|
| **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
62
README.zh.md
62
README.zh.md
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
||||||
|
|
||||||
|
|
@ -208,9 +208,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
### 🚀 快速开始
|
### 🚀 快速开始
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
|
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。获取 API Key: [火山引擎 (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。网络搜索是 **可选的** — 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)。
|
||||||
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
|
|
||||||
|
|
||||||
**1. 初始化 (Initialize)**
|
**1. 初始化 (Initialize)**
|
||||||
|
|
||||||
|
|
@ -226,7 +224,7 @@ picoclaw onboard
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt-5.4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20
|
||||||
|
|
@ -234,8 +232,13 @@ picoclaw onboard
|
||||||
},
|
},
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "your-api-key",
|
"api_key": "your-api-key",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
|
|
@ -514,8 +517,9 @@ Agent 读取 HEARTBEAT.md
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
|
||||||
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
|
| **火山引擎(Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
|
||||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
|
||||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
|
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
@ -526,8 +530,13 @@ Agent 读取 HEARTBEAT.md
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -543,7 +552,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -555,8 +564,18 @@ Agent 读取 HEARTBEAT.md
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**火山引擎(Doubao)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -622,14 +641,14 @@ Agent 读取 HEARTBEAT.md
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -875,8 +894,15 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
||||||
| 服务 | 免费层级 | 适用场景 |
|
| 服务 | 免费层级 | 适用场景 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
||||||
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
| **火山引擎 CodingPlan** | 9.9 元/首月 | 最适合国内用户,多种 SOTA 模型(豆包、DeepSeek 等) |
|
||||||
|
| **智谱 (Zhipu)** | 200K tokens/月 | 适合中国用户 |
|
||||||
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
||||||
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
|
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
|
||||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
||||||
| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) |
|
| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
BIN
assets/logo.webp
Normal file
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() {
|
Action: func() {
|
||||||
newName := s.nextAvailableModelName("new-model")
|
newName := s.nextAvailableModelName("new-model")
|
||||||
s.addModel(
|
s.addModel(
|
||||||
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"},
|
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
||||||
)
|
)
|
||||||
s.push(
|
s.push(
|
||||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||||
|
|
@ -291,7 +291,7 @@ func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||||
Action: func() {
|
Action: func() {
|
||||||
newName := s.nextAvailableModelName("new-model")
|
newName := s.nextAvailableModelName("new-model")
|
||||||
s.addModel(
|
s.addModel(
|
||||||
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"},
|
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
||||||
)
|
)
|
||||||
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
|
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -72,14 +72,14 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
||||||
// If no openai in ModelList, add it
|
// If no openai in ModelList, add it
|
||||||
if !foundOpenAI {
|
if !foundOpenAI {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
ModelName: "gpt-5.2",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.2",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update default model to use OpenAI
|
// Update default model to use OpenAI
|
||||||
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
|
appCfg.Agents.Defaults.ModelName = "gpt-5.4"
|
||||||
|
|
||||||
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||||
return fmt.Errorf("could not update config: %w", err)
|
return fmt.Errorf("could not update config: %w", err)
|
||||||
|
|
@ -90,7 +90,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
||||||
if cred.AccountID != "" {
|
if cred.AccountID != "" {
|
||||||
fmt.Printf("Account: %s\n", cred.AccountID)
|
fmt.Printf("Account: %s\n", cred.AccountID)
|
||||||
}
|
}
|
||||||
fmt.Println("Default model set to: gpt-5.2")
|
fmt.Println("Default model set to: gpt-5.4")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -318,13 +318,13 @@ func authLoginPasteToken(provider string) error {
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
ModelName: "gpt-5.2",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.2",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "token",
|
AuthMethod: "token",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Update default model
|
// Update default model
|
||||||
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
|
appCfg.Agents.Defaults.ModelName = "gpt-5.4"
|
||||||
}
|
}
|
||||||
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||||
return fmt.Errorf("could not update config: %w", err)
|
return fmt.Errorf("could not update config: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"restrict_to_workspace": true,
|
"restrict_to_workspace": true,
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt-5.4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20,
|
"max_tool_iterations": 20,
|
||||||
|
|
@ -13,8 +13,8 @@
|
||||||
},
|
},
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
},
|
},
|
||||||
|
|
@ -41,14 +41,14 @@
|
||||||
"api_key": "your-longcat-api-key"
|
"api_key": "your-longcat-api-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "loadbalanced-gpt4",
|
"model_name": "loadbalanced-gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key1",
|
"api_key": "sk-key1",
|
||||||
"api_base": "https://api1.example.com/v1"
|
"api_base": "https://api1.example.com/v1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "loadbalanced-gpt4",
|
"model_name": "loadbalanced-gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key2",
|
"api_key": "sk-key2",
|
||||||
"api_base": "https://api2.example.com/v1"
|
"api_base": "https://api2.example.com/v1"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ Problem: Agent needs to know both `provider` and `model`, adding complexity.
|
||||||
Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
|
Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
|
||||||
|
|
||||||
1. **Model-centric**: Users care about models, not providers
|
1. **Model-centric**: Users care about models, not providers
|
||||||
2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.2`, `anthropic/claude-sonnet-4.6`
|
2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.4`, `anthropic/claude-sonnet-4.6`
|
||||||
3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes
|
3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes
|
||||||
|
|
||||||
### 2.2 New Configuration Structure
|
### 2.2 New Configuration Structure
|
||||||
|
|
@ -81,8 +81,8 @@ Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
|
||||||
"api_key": "sk-xxx"
|
"api_key": "sk-xxx"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-xxx"
|
"api_key": "sk-xxx"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -128,7 +128,7 @@ type Config struct {
|
||||||
type ModelConfig struct {
|
type ModelConfig struct {
|
||||||
// Required
|
// Required
|
||||||
ModelName string `json:"model_name"` // user-facing name (alias)
|
ModelName string `json:"model_name"` // user-facing name (alias)
|
||||||
Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.2
|
Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.4
|
||||||
|
|
||||||
// Common config
|
// Common config
|
||||||
APIBase string `json:"api_base,omitempty"`
|
APIBase string `json:"api_base,omitempty"`
|
||||||
|
|
@ -180,7 +180,7 @@ Identify protocol via prefix in `model` field:
|
||||||
"model": "deepseek-chat"
|
"model": "deepseek-chat"
|
||||||
},
|
},
|
||||||
"coder": {
|
"coder": {
|
||||||
"model": "gpt-5.2",
|
"model": "gpt-5.4",
|
||||||
"system_prompt": "You are a coding assistant..."
|
"system_prompt": "You are a coding assistant..."
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
|
|
@ -200,7 +200,7 @@ Each Agent only needs to specify `model` (corresponds to `model_name` in `model_
|
||||||
model_list:
|
model_list:
|
||||||
- model_name: gpt-4o
|
- model_name: gpt-4o
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: openai/gpt-5.2
|
model: openai/gpt-5.4
|
||||||
api_key: xxx
|
api_key: xxx
|
||||||
- model_name: my-custom
|
- model_name: my-custom
|
||||||
litellm_params:
|
litellm_params:
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ The new `model_list` configuration offers several advantages:
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +53,7 @@ The new `model_list` configuration offers several advantages:
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
},
|
},
|
||||||
|
|
@ -82,7 +82,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
||||||
|
|
||||||
| Prefix | Description | Example |
|
| Prefix | Description | Example |
|
||||||
|--------|-------------|---------|
|
|--------|-------------|---------|
|
||||||
| `openai/` | OpenAI API (default) | `openai/gpt-5.2` |
|
| `openai/` | OpenAI API (default) | `openai/gpt-5.4` |
|
||||||
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
|
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
|
||||||
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
|
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
|
||||||
| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |
|
| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |
|
||||||
|
|
@ -109,7 +109,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
||||||
| Field | Required | Description |
|
| Field | Required | Description |
|
||||||
|-------|----------|-------------|
|
|-------|----------|-------------|
|
||||||
| `model_name` | Yes | User-facing alias for the model |
|
| `model_name` | Yes | User-facing alias for the model |
|
||||||
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.2`) |
|
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.4`) |
|
||||||
| `api_base` | No | API endpoint URL |
|
| `api_base` | No | API endpoint URL |
|
||||||
| `api_key` | No* | API authentication key |
|
| `api_key` | No* | API authentication key |
|
||||||
| `proxy` | No | HTTP proxy URL |
|
| `proxy` | No | HTTP proxy URL |
|
||||||
|
|
@ -130,19 +130,19 @@ Configure multiple endpoints for the same model to distribute load:
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key1",
|
"api_key": "sk-key1",
|
||||||
"api_base": "https://api1.example.com/v1"
|
"api_base": "https://api1.example.com/v1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key2",
|
"api_key": "sk-key2",
|
||||||
"api_base": "https://api2.example.com/v1"
|
"api_base": "https://api2.example.com/v1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key3",
|
"api_key": "sk-key3",
|
||||||
"api_base": "https://api3.example.com/v1"
|
"api_base": "https://api3.example.com/v1"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ const (
|
||||||
lineBotInfoEndpoint = lineAPIBase + "/info"
|
lineBotInfoEndpoint = lineAPIBase + "/info"
|
||||||
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
|
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
|
||||||
lineReplyTokenMaxAge = 25 * time.Second
|
lineReplyTokenMaxAge = 25 * time.Second
|
||||||
|
|
||||||
|
// Limit request body to prevent memory exhaustion (DoS).
|
||||||
|
// LINE webhook payloads are typically a few KB; 1 MiB is generous.
|
||||||
|
maxWebhookBodySize = 1 << 20 // 1 MiB
|
||||||
)
|
)
|
||||||
|
|
||||||
type replyTokenEntry struct {
|
type replyTokenEntry struct {
|
||||||
|
|
@ -166,7 +170,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("line", "Failed to read request body", map[string]any{
|
logger.ErrorCF("line", "Failed to read request body", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -174,6 +178,11 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if int64(len(body)) > maxWebhookBodySize {
|
||||||
|
logger.WarnC("line", "Webhook request body too large, rejected")
|
||||||
|
http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
signature := r.Header.Get("X-Line-Signature")
|
signature := r.Header.Get("X-Line-Signature")
|
||||||
if !c.verifySignature(body, signature) {
|
if !c.verifySignature(body, signature) {
|
||||||
|
|
|
||||||
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.
|
// Implements PlaceholderRecorder.
|
||||||
func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
|
func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
|
||||||
key := channel + ":" + chatID
|
key := channel + ":" + chatID
|
||||||
m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()})
|
entry := typingEntry{stop: stop, createdAt: time.Now()}
|
||||||
|
if previous, loaded := m.typingStops.Swap(key, entry); loaded {
|
||||||
|
if oldEntry, ok := previous.(typingEntry); ok && oldEntry.stop != nil {
|
||||||
|
oldEntry.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordReactionUndo registers a reaction undo function for later invocation.
|
// RecordReactionUndo registers a reaction undo function for later invocation.
|
||||||
|
|
|
||||||
|
|
@ -616,6 +616,37 @@ func TestRecordTypingStop_ConcurrentSafe(t *testing.T) {
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRecordTypingStop_ReplacesExistingStop(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var oldStopCalls int
|
||||||
|
var newStopCalls int
|
||||||
|
|
||||||
|
m.RecordTypingStop("test", "123", func() {
|
||||||
|
oldStopCalls++
|
||||||
|
})
|
||||||
|
|
||||||
|
m.RecordTypingStop("test", "123", func() {
|
||||||
|
newStopCalls++
|
||||||
|
})
|
||||||
|
|
||||||
|
if oldStopCalls != 1 {
|
||||||
|
t.Fatalf("expected previous typing stop to be called once when replaced, got %d", oldStopCalls)
|
||||||
|
}
|
||||||
|
if newStopCalls != 0 {
|
||||||
|
t.Fatalf("expected replacement typing stop to stay active until preSend, got %d calls", newStopCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
|
||||||
|
m.preSend(context.Background(), "test", msg, &mockChannel{})
|
||||||
|
|
||||||
|
if newStopCalls != 1 {
|
||||||
|
t.Fatalf("expected replacement typing stop to be called by preSend, got %d", newStopCalls)
|
||||||
|
}
|
||||||
|
if oldStopCalls != 1 {
|
||||||
|
t.Fatalf("expected previous typing stop to not be called again, got %d", oldStopCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) {
|
func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) {
|
||||||
m := newTestManager()
|
m := newTestManager()
|
||||||
var sendCalled bool
|
var sendCalled bool
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -726,17 +727,23 @@ func (c *MatrixChannel) downloadMedia(
|
||||||
reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second)
|
reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
data, err := c.client.DownloadBytes(reqCtx, parsed)
|
resp, err := c.client.Download(reqCtx, parsed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
reader := resp.Body
|
||||||
|
readerClose := func() error { return nil }
|
||||||
|
|
||||||
// Encrypted attachments put URL in msgEvt.File and require client-side decryption.
|
// Encrypted attachments put URL in msgEvt.File and require client-side decryption.
|
||||||
if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" {
|
if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" {
|
||||||
err = msgEvt.File.DecryptInPlace(data)
|
if err = msgEvt.File.PrepareForDecryption(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("decrypt matrix media: %w", err)
|
return "", fmt.Errorf("decrypt matrix media: %w", err)
|
||||||
}
|
}
|
||||||
|
decryptReader := msgEvt.File.DecryptStream(resp.Body)
|
||||||
|
reader = decryptReader
|
||||||
|
readerClose = decryptReader.Close
|
||||||
}
|
}
|
||||||
|
|
||||||
label := matrixMediaLabel(msgEvt, mediaKind)
|
label := matrixMediaLabel(msgEvt, mediaKind)
|
||||||
|
|
@ -749,14 +756,28 @@ func (c *MatrixChannel) downloadMedia(
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer tmp.Close()
|
tmpPath := tmp.Name()
|
||||||
|
cleanup := true
|
||||||
|
defer func() {
|
||||||
|
_ = tmp.Close()
|
||||||
|
if cleanup {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if _, err = tmp.Write(data); err != nil {
|
_, err = io.Copy(tmp, reader)
|
||||||
_ = os.Remove(tmp.Name())
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err = readerClose(); err != nil {
|
||||||
|
return "", fmt.Errorf("decrypt matrix media: %w", err)
|
||||||
|
}
|
||||||
|
if err = tmp.Close(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tmp.Name(), nil
|
cleanup = false
|
||||||
|
return tmpPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func matrixContentType(msgEvt *event.MessageEventContent) string {
|
func matrixContentType(msgEvt *event.MessageEventContent) string {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package matrix
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -197,6 +199,50 @@ func TestMatrixMediaExt(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDownloadMedia_WritesResponseToTempFile(t *testing.T) {
|
||||||
|
const wantBody = "matrix-media-payload"
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasSuffix(r.URL.Path, "/_matrix/client/v1/media/download/matrix.test/abc123") {
|
||||||
|
t.Fatalf("unexpected download path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write([]byte(wantBody))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := mautrix.NewClient(server.URL, id.UserID("@picoclaw:matrix.test"), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewClient: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := &MatrixChannel{client: client}
|
||||||
|
msg := &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgImage,
|
||||||
|
Body: "image.png",
|
||||||
|
URL: id.ContentURIString("mxc://matrix.test/abc123"),
|
||||||
|
Info: &event.FileInfo{MimeType: "image/png"},
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := ch.downloadMedia(context.Background(), msg, "image")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("downloadMedia: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
if ext := filepath.Ext(path); ext != ".png" {
|
||||||
|
t.Fatalf("temp file extension=%q want=.png", ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != wantBody {
|
||||||
|
t.Fatalf("file contents=%q want=%q", string(got), wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) {
|
func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) {
|
||||||
ch := &MatrixChannel{}
|
ch := &MatrixChannel{}
|
||||||
msg := &event.MessageEventContent{
|
msg := &event.MessageEventContent{
|
||||||
|
|
|
||||||
|
|
@ -444,7 +444,7 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) {
|
||||||
configPath := filepath.Join(tmpDir, "config.json")
|
configPath := filepath.Join(tmpDir, "config.json")
|
||||||
configJSON := `{
|
configJSON := `{
|
||||||
"agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}},
|
"agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}},
|
||||||
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}],
|
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.4","api_key":"x"}],
|
||||||
"tools": {"web":{"proxy":"http://127.0.0.1:7890"}}
|
"tools": {"web":{"proxy":"http://127.0.0.1:7890"}}
|
||||||
}`
|
}`
|
||||||
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
|
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -194,8 +194,8 @@ func DefaultConfig() *Config {
|
||||||
|
|
||||||
// OpenAI - https://platform.openai.com/api-keys
|
// OpenAI - https://platform.openai.com/api-keys
|
||||||
{
|
{
|
||||||
ModelName: "gpt-5.2",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.2",
|
Model: "openai/gpt-5.4",
|
||||||
APIBase: "https://api.openai.com/v1",
|
APIBase: "https://api.openai.com/v1",
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
},
|
},
|
||||||
|
|
@ -256,8 +256,8 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "openrouter-gpt-5.2",
|
ModelName: "openrouter-gpt-5.4",
|
||||||
Model: "openrouter/openai/gpt-5.2",
|
Model: "openrouter/openai/gpt-5.4",
|
||||||
APIBase: "https://openrouter.ai/api/v1",
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
},
|
},
|
||||||
|
|
@ -287,6 +287,12 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Volcengine (火山引擎) - https://console.volcengine.com/ark
|
// Volcengine (火山引擎) - https://console.volcengine.com/ark
|
||||||
|
{
|
||||||
|
ModelName: "ark-code-latest",
|
||||||
|
Model: "volcengine/ark-code-latest",
|
||||||
|
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ModelName: "doubao-pro",
|
ModelName: "doubao-pro",
|
||||||
Model: "volcengine/doubao-pro-32k",
|
Model: "volcengine/doubao-pro-32k",
|
||||||
|
|
@ -311,8 +317,8 @@ func DefaultConfig() *Config {
|
||||||
|
|
||||||
// GitHub Copilot - https://github.com/settings/tokens
|
// GitHub Copilot - https://github.com/settings/tokens
|
||||||
{
|
{
|
||||||
ModelName: "copilot-gpt-5.2",
|
ModelName: "copilot-gpt-5.4",
|
||||||
Model: "github-copilot/gpt-5.2",
|
Model: "github-copilot/gpt-5.4",
|
||||||
APIBase: "http://localhost:4321",
|
APIBase: "http://localhost:4321",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "openai",
|
ModelName: "openai",
|
||||||
Model: "openai/gpt-5.2",
|
Model: "openai/gpt-5.4",
|
||||||
APIKey: p.OpenAI.APIKey,
|
APIKey: p.OpenAI.APIKey,
|
||||||
APIBase: p.OpenAI.APIBase,
|
APIBase: p.OpenAI.APIBase,
|
||||||
Proxy: p.OpenAI.Proxy,
|
Proxy: p.OpenAI.Proxy,
|
||||||
|
|
@ -335,7 +335,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "github-copilot",
|
ModelName: "github-copilot",
|
||||||
Model: "github-copilot/gpt-5.2",
|
Model: "github-copilot/gpt-5.4",
|
||||||
APIBase: p.GitHubCopilot.APIBase,
|
APIBase: p.GitHubCopilot.APIBase,
|
||||||
ConnectMode: p.GitHubCopilot.ConnectMode,
|
ConnectMode: p.GitHubCopilot.ConnectMode,
|
||||||
}, true
|
}, true
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
|
||||||
if result[0].ModelName != "openai" {
|
if result[0].ModelName != "openai" {
|
||||||
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai")
|
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai")
|
||||||
}
|
}
|
||||||
if result[0].Model != "openai/gpt-5.2" {
|
if result[0].Model != "openai/gpt-5.4" {
|
||||||
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.2")
|
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.4")
|
||||||
}
|
}
|
||||||
if result[0].APIKey != "sk-test-key" {
|
if result[0].APIKey != "sk-test-key" {
|
||||||
t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key")
|
t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key")
|
||||||
|
|
@ -384,8 +384,8 @@ func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *tes
|
||||||
for _, mc := range result {
|
for _, mc := range result {
|
||||||
switch mc.ModelName {
|
switch mc.ModelName {
|
||||||
case "openai":
|
case "openai":
|
||||||
if mc.Model != "openai/gpt-5.2" {
|
if mc.Model != "openai/gpt-5.4" {
|
||||||
t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.2")
|
t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.4")
|
||||||
}
|
}
|
||||||
case "deepseek":
|
case "deepseek":
|
||||||
if mc.Model != "deepseek/deepseek-reasoner" {
|
if mc.Model != "deepseek/deepseek-reasoner" {
|
||||||
|
|
@ -558,9 +558,9 @@ func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
|
||||||
// Tests for buildModelWithProtocol helper function
|
// Tests for buildModelWithProtocol helper function
|
||||||
|
|
||||||
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
|
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
|
||||||
result := buildModelWithProtocol("openai", "gpt-5.2")
|
result := buildModelWithProtocol("openai", "gpt-5.4")
|
||||||
if result != "openai/gpt-5.2" {
|
if result != "openai/gpt-5.4" {
|
||||||
t.Errorf("buildModelWithProtocol(openai, gpt-5.2) = %q, want %q", result, "openai/gpt-5.2")
|
t.Errorf("buildModelWithProtocol(openai, gpt-5.4) = %q, want %q", result, "openai/gpt-5.4")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -490,7 +490,7 @@ echo '{"type":"turn.completed"}'`
|
||||||
}
|
}
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "test"}}
|
messages := []Message{{Role: "user", Content: "test"}}
|
||||||
_, err := p.Chat(context.Background(), messages, nil, "gpt-5.2-codex", nil)
|
_, err := p.Chat(context.Background(), messages, nil, "gpt-5.3-codex", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error: %v", err)
|
t.Fatalf("Chat() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -502,7 +502,7 @@ echo '{"type":"turn.completed"}'`
|
||||||
}
|
}
|
||||||
args := string(argsData)
|
args := string(argsData)
|
||||||
|
|
||||||
if !strings.Contains(args, "-m gpt-5.2-codex") {
|
if !strings.Contains(args, "-m gpt-5.3-codex") {
|
||||||
t.Errorf("args should contain model flag, got: %s", args)
|
t.Errorf("args should contain model flag, got: %s", args)
|
||||||
}
|
}
|
||||||
if !strings.Contains(args, "-C /tmp/test-workspace") {
|
if !strings.Contains(args, "-C /tmp/test-workspace") {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
codexDefaultModel = "gpt-5.2"
|
codexDefaultModel = "gpt-5.3-codex"
|
||||||
codexDefaultInstructions = "You are Codex, a coding assistant."
|
codexDefaultInstructions = "You are Codex, a coding assistant."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -568,7 +568,7 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T)
|
||||||
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
|
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||||
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.2", nil)
|
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.3-codex", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error: %v", err)
|
t.Fatalf("Chat() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -599,7 +599,7 @@ func TestResolveCodexModel(t *testing.T) {
|
||||||
wantFallback: true,
|
wantFallback: true,
|
||||||
},
|
},
|
||||||
{name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true},
|
{name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true},
|
||||||
{name: "openai prefix", input: "openai/gpt-5.2", wantModel: "gpt-5.2", wantFallback: false},
|
{name: "openai prefix", input: "openai/gpt-5.3-codex", wantModel: "gpt-5.3-codex", wantFallback: false},
|
||||||
{name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false},
|
{name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,7 @@ func TestGatewayStartReady_OAuthModelRequiresStoredCredential(t *testing.T) {
|
||||||
}
|
}
|
||||||
cfg.ModelList = []config.ModelConfig{{
|
cfg.ModelList = []config.ModelConfig{{
|
||||||
ModelName: "openai-oauth",
|
ModelName: "openai-oauth",
|
||||||
Model: "openai/gpt-5.2",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
}}
|
}}
|
||||||
cfg.Agents.Defaults.ModelName = "openai-oauth"
|
cfg.Agents.Defaults.ModelName = "openai-oauth"
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
|
||||||
cfg.ModelList = []config.ModelConfig{
|
cfg.ModelList = []config.ModelConfig{
|
||||||
{
|
{
|
||||||
ModelName: "openai-oauth",
|
ModelName: "openai-oauth",
|
||||||
Model: "openai/gpt-5.2",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -81,8 +81,8 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
|
||||||
APIKey: "remote-key",
|
APIKey: "remote-key",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "copilot-gpt-5.2",
|
ModelName: "copilot-gpt-5.4",
|
||||||
Model: "github-copilot/gpt-5.2",
|
Model: "github-copilot/gpt-5.4",
|
||||||
APIBase: "http://127.0.0.1:4321",
|
APIBase: "http://127.0.0.1:4321",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
},
|
},
|
||||||
|
|
@ -128,7 +128,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
|
||||||
if !got["vllm-remote"] {
|
if !got["vllm-remote"] {
|
||||||
t.Fatalf("remote vllm model configured = false, want true with api_key")
|
t.Fatalf("remote vllm model configured = false, want true with api_key")
|
||||||
}
|
}
|
||||||
if !got["copilot-gpt-5.2"] {
|
if !got["copilot-gpt-5.4"] {
|
||||||
t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds")
|
t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds")
|
||||||
}
|
}
|
||||||
if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model" {
|
if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model" {
|
||||||
|
|
|
||||||
|
|
@ -791,8 +791,8 @@ func defaultModelConfigForProvider(provider, authMethod string) config.ModelConf
|
||||||
switch provider {
|
switch provider {
|
||||||
case oauthProviderOpenAI:
|
case oauthProviderOpenAI:
|
||||||
return config.ModelConfig{
|
return config.ModelConfig{
|
||||||
ModelName: "gpt-5.2",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.2",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: authMethod,
|
AuthMethod: authMethod,
|
||||||
}
|
}
|
||||||
case oauthProviderAnthropic:
|
case oauthProviderAnthropic:
|
||||||
|
|
|
||||||
|
|
@ -168,8 +168,8 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
|
||||||
}
|
}
|
||||||
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||||
ModelName: "gpt-5.2",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.2",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
})
|
})
|
||||||
if err = config.SaveConfig(configPath, cfg); err != nil {
|
if err = config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
|
|
||||||
3
web/frontend/.gitignore
vendored
3
web/frontend/.gitignore
vendored
|
|
@ -1,5 +1,4 @@
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
|
@ -23,4 +22,4 @@ dist-ssr
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
.tanstack
|
.tanstack
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"wrap-ansi": "^10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|
|
||||||
28
web/frontend/pnpm-lock.yaml
generated
28
web/frontend/pnpm-lock.yaml
generated
|
|
@ -80,6 +80,9 @@ importers:
|
||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
|
wrap-ansi:
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.39.1
|
specifier: ^9.39.1
|
||||||
|
|
@ -1837,6 +1840,10 @@ packages:
|
||||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
ansi-styles@6.2.3:
|
||||||
|
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
ansis@4.2.0:
|
ansis@4.2.0:
|
||||||
resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
|
resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
@ -3558,6 +3565,10 @@ packages:
|
||||||
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
string-width@8.2.0:
|
||||||
|
resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
stringify-entities@4.0.4:
|
stringify-entities@4.0.4:
|
||||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||||
|
|
||||||
|
|
@ -3883,6 +3894,10 @@ packages:
|
||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
wrap-ansi@10.0.0:
|
||||||
|
resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
wrap-ansi@6.2.0:
|
wrap-ansi@6.2.0:
|
||||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
@ -5677,6 +5692,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-convert: 2.0.1
|
color-convert: 2.0.1
|
||||||
|
|
||||||
|
ansi-styles@6.2.3: {}
|
||||||
|
|
||||||
ansis@4.2.0: {}
|
ansis@4.2.0: {}
|
||||||
|
|
||||||
anymatch@3.1.3:
|
anymatch@3.1.3:
|
||||||
|
|
@ -7628,6 +7645,11 @@ snapshots:
|
||||||
get-east-asian-width: 1.5.0
|
get-east-asian-width: 1.5.0
|
||||||
strip-ansi: 7.2.0
|
strip-ansi: 7.2.0
|
||||||
|
|
||||||
|
string-width@8.2.0:
|
||||||
|
dependencies:
|
||||||
|
get-east-asian-width: 1.5.0
|
||||||
|
strip-ansi: 7.2.0
|
||||||
|
|
||||||
stringify-entities@4.0.4:
|
stringify-entities@4.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
character-entities-html4: 2.1.0
|
character-entities-html4: 2.1.0
|
||||||
|
|
@ -7904,6 +7926,12 @@ snapshots:
|
||||||
|
|
||||||
word-wrap@1.2.5: {}
|
word-wrap@1.2.5: {}
|
||||||
|
|
||||||
|
wrap-ansi@10.0.0:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 6.2.3
|
||||||
|
string-width: 8.2.0
|
||||||
|
strip-ansi: 7.2.0
|
||||||
|
|
||||||
wrap-ansi@6.2.0:
|
wrap-ansi@6.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
setLauncherConfig as updateLauncherConfig,
|
setLauncherConfig as updateLauncherConfig,
|
||||||
} from "@/api/system"
|
} from "@/api/system"
|
||||||
import {
|
import {
|
||||||
AdvancedSection,
|
|
||||||
AgentDefaultsSection,
|
AgentDefaultsSection,
|
||||||
DevicesSection,
|
DevicesSection,
|
||||||
LauncherSection,
|
LauncherSection,
|
||||||
|
|
@ -30,7 +29,6 @@ import {
|
||||||
} from "@/components/config/form-model"
|
} from "@/components/config/form-model"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
|
|
||||||
export function ConfigPage() {
|
export function ConfigPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
@ -56,11 +54,7 @@ export function ConfigPage() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const { data: launcherConfig, isLoading: isLauncherLoading } = useQuery({
|
||||||
data: launcherConfig,
|
|
||||||
isLoading: isLauncherLoading,
|
|
||||||
error: launcherError,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["system", "launcher-config"],
|
queryKey: ["system", "launcher-config"],
|
||||||
queryFn: getLauncherConfig,
|
queryFn: getLauncherConfig,
|
||||||
})
|
})
|
||||||
|
|
@ -111,10 +105,6 @@ export function ConfigPage() {
|
||||||
? t("pages.config.autostart_unsupported")
|
? t("pages.config.autostart_unsupported")
|
||||||
: t("pages.config.autostart_hint")
|
: t("pages.config.autostart_hint")
|
||||||
|
|
||||||
const launcherHint = launcherError
|
|
||||||
? t("pages.config.launcher_load_error")
|
|
||||||
: t("pages.config.launcher_restart_hint")
|
|
||||||
|
|
||||||
const updateField = <K extends keyof CoreConfigForm>(
|
const updateField = <K extends keyof CoreConfigForm>(
|
||||||
key: K,
|
key: K,
|
||||||
value: CoreConfigForm[K],
|
value: CoreConfigForm[K],
|
||||||
|
|
@ -287,21 +277,14 @@ export function ConfigPage() {
|
||||||
|
|
||||||
<AgentDefaultsSection form={form} onFieldChange={updateField} />
|
<AgentDefaultsSection form={form} onFieldChange={updateField} />
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<RuntimeSection form={form} onFieldChange={updateField} />
|
<RuntimeSection form={form} onFieldChange={updateField} />
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<LauncherSection
|
<LauncherSection
|
||||||
launcherForm={launcherForm}
|
launcherForm={launcherForm}
|
||||||
onFieldChange={updateLauncherField}
|
onFieldChange={updateLauncherField}
|
||||||
launcherHint={launcherHint}
|
|
||||||
disabled={saving || isLauncherLoading}
|
disabled={saving || isLauncherLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<DevicesSection
|
<DevicesSection
|
||||||
form={form}
|
form={form}
|
||||||
onFieldChange={updateField}
|
onFieldChange={updateField}
|
||||||
|
|
@ -316,10 +299,6 @@ export function ConfigPage() {
|
||||||
onAutoStartChange={setAutoStartEnabled}
|
onAutoStartChange={setAutoStartEnabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<AdvancedSection />
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { IconCode } from "@tabler/icons-react"
|
import type { ReactNode } from "react"
|
||||||
import { Link } from "@tanstack/react-router"
|
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -8,7 +7,13 @@ import {
|
||||||
type LauncherForm,
|
type LauncherForm,
|
||||||
} from "@/components/config/form-model"
|
} from "@/components/config/form-model"
|
||||||
import { Field, SwitchCardField } from "@/components/shared-form"
|
import { Field, SwitchCardField } from "@/components/shared-form"
|
||||||
import { Button } from "@/components/ui/button"
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -29,6 +34,30 @@ type UpdateLauncherField = <K extends keyof LauncherForm>(
|
||||||
value: LauncherForm[K],
|
value: LauncherForm[K],
|
||||||
) => void
|
) => void
|
||||||
|
|
||||||
|
interface ConfigSectionCardProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigSectionCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: ConfigSectionCardProps) {
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader className="border-border border-b">
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
{description && <CardDescription>{description}</CardDescription>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="divide-border/70 divide-y">{children}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface AgentDefaultsSectionProps {
|
interface AgentDefaultsSectionProps {
|
||||||
form: CoreConfigForm
|
form: CoreConfigForm
|
||||||
onFieldChange: UpdateCoreField
|
onFieldChange: UpdateCoreField
|
||||||
|
|
@ -41,89 +70,94 @@ export function AgentDefaultsSection({
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<ConfigSectionCard title={t("pages.config.sections.agent")}>
|
||||||
<div className="space-y-4">
|
<Field
|
||||||
<Field
|
label={t("pages.config.workspace")}
|
||||||
label={t("pages.config.workspace")}
|
hint={t("pages.config.workspace_hint")}
|
||||||
hint={t("pages.config.workspace_hint")}
|
layout="setting-row"
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.workspace}
|
value={form.workspace}
|
||||||
onChange={(e) => onFieldChange("workspace", e.target.value)}
|
onChange={(e) => onFieldChange("workspace", e.target.value)}
|
||||||
placeholder="~/.picoclaw/workspace"
|
placeholder="~/.picoclaw/workspace"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("pages.config.restrict_workspace")}
|
label={t("pages.config.restrict_workspace")}
|
||||||
hint={t("pages.config.restrict_workspace_hint")}
|
hint={t("pages.config.restrict_workspace_hint")}
|
||||||
checked={form.restrictToWorkspace}
|
layout="setting-row"
|
||||||
onCheckedChange={(checked) =>
|
checked={form.restrictToWorkspace}
|
||||||
onFieldChange("restrictToWorkspace", checked)
|
onCheckedChange={(checked) =>
|
||||||
|
onFieldChange("restrictToWorkspace", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("pages.config.allow_remote")}
|
||||||
|
hint={t("pages.config.allow_remote_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
checked={form.allowRemote}
|
||||||
|
onCheckedChange={(checked) => onFieldChange("allowRemote", checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.max_tokens")}
|
||||||
|
hint={t("pages.config.max_tokens_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.maxTokens}
|
||||||
|
onChange={(e) => onFieldChange("maxTokens", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.max_tool_iterations")}
|
||||||
|
hint={t("pages.config.max_tool_iterations_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.maxToolIterations}
|
||||||
|
onChange={(e) => onFieldChange("maxToolIterations", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.summarize_threshold")}
|
||||||
|
hint={t("pages.config.summarize_threshold_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.summarizeMessageThreshold}
|
||||||
|
onChange={(e) =>
|
||||||
|
onFieldChange("summarizeMessageThreshold", e.target.value)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<SwitchCardField
|
<Field
|
||||||
label={t("pages.config.allow_remote")}
|
label={t("pages.config.summarize_token_percent")}
|
||||||
hint={t("pages.config.allow_remote_hint")}
|
hint={t("pages.config.summarize_token_percent_hint")}
|
||||||
checked={form.allowRemote}
|
layout="setting-row"
|
||||||
onCheckedChange={(checked) => onFieldChange("allowRemote", checked)}
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={form.summarizeTokenPercent}
|
||||||
|
onChange={(e) =>
|
||||||
|
onFieldChange("summarizeTokenPercent", e.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
<Field
|
</ConfigSectionCard>
|
||||||
label={t("pages.config.max_tokens")}
|
|
||||||
hint={t("pages.config.max_tokens_hint")}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={form.maxTokens}
|
|
||||||
onChange={(e) => onFieldChange("maxTokens", e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
label={t("pages.config.max_tool_iterations")}
|
|
||||||
hint={t("pages.config.max_tool_iterations_hint")}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={form.maxToolIterations}
|
|
||||||
onChange={(e) => onFieldChange("maxToolIterations", e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
label={t("pages.config.summarize_threshold")}
|
|
||||||
hint={t("pages.config.summarize_threshold_hint")}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={form.summarizeMessageThreshold}
|
|
||||||
onChange={(e) =>
|
|
||||||
onFieldChange("summarizeMessageThreshold", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
label={t("pages.config.summarize_token_percent")}
|
|
||||||
hint={t("pages.config.summarize_token_percent_hint")}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
value={form.summarizeTokenPercent}
|
|
||||||
onChange={(e) =>
|
|
||||||
onFieldChange("summarizeTokenPercent", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,126 +173,123 @@ export function RuntimeSection({ form, onFieldChange }: RuntimeSectionProps) {
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<ConfigSectionCard title={t("pages.config.sections.runtime")}>
|
||||||
<div className="space-y-4">
|
<Field
|
||||||
<Field
|
label={t("pages.config.session_scope")}
|
||||||
label={t("pages.config.session_scope")}
|
hint={t("pages.config.session_scope_hint")}
|
||||||
hint={t("pages.config.session_scope_hint")}
|
layout="setting-row"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={form.dmScope}
|
||||||
|
onValueChange={(value) => onFieldChange("dmScope", value)}
|
||||||
>
|
>
|
||||||
<Select
|
<SelectTrigger className="w-full">
|
||||||
value={form.dmScope}
|
<SelectValue>
|
||||||
onValueChange={(value) => onFieldChange("dmScope", value)}
|
{selectedDmScopeOption
|
||||||
>
|
? t(
|
||||||
<SelectTrigger>
|
selectedDmScopeOption.labelKey,
|
||||||
<SelectValue>
|
selectedDmScopeOption.labelDefault,
|
||||||
{selectedDmScopeOption
|
)
|
||||||
? t(
|
: form.dmScope}
|
||||||
selectedDmScopeOption.labelKey,
|
</SelectValue>
|
||||||
selectedDmScopeOption.labelDefault,
|
</SelectTrigger>
|
||||||
)
|
<SelectContent>
|
||||||
: form.dmScope}
|
{DM_SCOPE_OPTIONS.map((scope) => (
|
||||||
</SelectValue>
|
<SelectItem key={scope.value} value={scope.value}>
|
||||||
</SelectTrigger>
|
<div className="flex flex-col gap-0.5">
|
||||||
<SelectContent>
|
<span className="font-medium">{t(scope.labelKey)}</span>
|
||||||
{DM_SCOPE_OPTIONS.map((scope) => (
|
<span className="text-muted-foreground text-xs">
|
||||||
<SelectItem key={scope.value} value={scope.value}>
|
{t(scope.descKey)}
|
||||||
<div className="flex flex-col gap-0.5">
|
</span>
|
||||||
<span className="font-medium">{t(scope.labelKey)}</span>
|
</div>
|
||||||
<span className="text-muted-foreground text-xs">
|
</SelectItem>
|
||||||
{t(scope.descKey)}
|
))}
|
||||||
</span>
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
</SelectItem>
|
</Field>
|
||||||
))}
|
|
||||||
</SelectContent>
|
<SwitchCardField
|
||||||
</Select>
|
label={t("pages.config.heartbeat_enabled")}
|
||||||
|
hint={t("pages.config.heartbeat_enabled_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
checked={form.heartbeatEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onFieldChange("heartbeatEnabled", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.heartbeatEnabled && (
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.heartbeat_interval")}
|
||||||
|
hint={t("pages.config.heartbeat_interval_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.heartbeatInterval}
|
||||||
|
onChange={(e) => onFieldChange("heartbeatInterval", e.target.value)}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
)}
|
||||||
<SwitchCardField
|
</ConfigSectionCard>
|
||||||
label={t("pages.config.heartbeat_enabled")}
|
|
||||||
hint={t("pages.config.heartbeat_enabled_hint")}
|
|
||||||
checked={form.heartbeatEnabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onFieldChange("heartbeatEnabled", checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{form.heartbeatEnabled && (
|
|
||||||
<Field
|
|
||||||
label={t("pages.config.heartbeat_interval")}
|
|
||||||
hint={t("pages.config.heartbeat_interval_hint")}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={form.heartbeatInterval}
|
|
||||||
onChange={(e) =>
|
|
||||||
onFieldChange("heartbeatInterval", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LauncherSectionProps {
|
interface LauncherSectionProps {
|
||||||
launcherForm: LauncherForm
|
launcherForm: LauncherForm
|
||||||
onFieldChange: UpdateLauncherField
|
onFieldChange: UpdateLauncherField
|
||||||
launcherHint: string
|
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LauncherSection({
|
export function LauncherSection({
|
||||||
launcherForm,
|
launcherForm,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
launcherHint,
|
|
||||||
disabled,
|
disabled,
|
||||||
}: LauncherSectionProps) {
|
}: LauncherSectionProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<ConfigSectionCard title={t("pages.config.sections.launcher")}>
|
||||||
<div className="space-y-4">
|
<SwitchCardField
|
||||||
<Field
|
label={t("pages.config.lan_access")}
|
||||||
label={t("pages.config.server_port")}
|
hint={t("pages.config.lan_access_hint")}
|
||||||
hint={t("pages.config.server_port_hint")}
|
layout="setting-row"
|
||||||
>
|
checked={launcherForm.publicAccess}
|
||||||
<Input
|
disabled={disabled}
|
||||||
type="number"
|
onCheckedChange={(checked) => onFieldChange("publicAccess", checked)}
|
||||||
min={1}
|
/>
|
||||||
max={65535}
|
|
||||||
value={launcherForm.port}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => onFieldChange("port", e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<SwitchCardField
|
<Field
|
||||||
label={t("pages.config.lan_access")}
|
label={t("pages.config.server_port")}
|
||||||
hint={t("pages.config.lan_access_hint")}
|
hint={t("pages.config.server_port_hint")}
|
||||||
checked={launcherForm.publicAccess}
|
layout="setting-row"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={launcherForm.port}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onCheckedChange={(checked) => onFieldChange("publicAccess", checked)}
|
onChange={(e) => onFieldChange("port", e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("pages.config.allowed_cidrs")}
|
label={t("pages.config.allowed_cidrs")}
|
||||||
hint={t("pages.config.allowed_cidrs_hint")}
|
hint={t("pages.config.allowed_cidrs_hint")}
|
||||||
>
|
layout="setting-row"
|
||||||
<Textarea
|
controlClassName="md:max-w-md"
|
||||||
value={launcherForm.allowedCIDRsText}
|
>
|
||||||
disabled={disabled}
|
<Textarea
|
||||||
placeholder={t("pages.config.allowed_cidrs_placeholder")}
|
value={launcherForm.allowedCIDRsText}
|
||||||
className="min-h-[88px]"
|
disabled={disabled}
|
||||||
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
|
placeholder={t("pages.config.allowed_cidrs_placeholder")}
|
||||||
/>
|
className="min-h-[88px]"
|
||||||
</Field>
|
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
|
||||||
|
/>
|
||||||
<p className="text-muted-foreground text-xs">{launcherHint}</p>
|
</Field>
|
||||||
</div>
|
</ConfigSectionCard>
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -282,52 +313,31 @@ export function DevicesSection({
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<ConfigSectionCard title={t("pages.config.sections.devices")}>
|
||||||
<div className="space-y-4">
|
<SwitchCardField
|
||||||
<SwitchCardField
|
label={t("pages.config.devices_enabled")}
|
||||||
label={t("pages.config.devices_enabled")}
|
hint={t("pages.config.devices_enabled_hint")}
|
||||||
hint={t("pages.config.devices_enabled_hint")}
|
layout="setting-row"
|
||||||
checked={form.devicesEnabled}
|
checked={form.devicesEnabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) => onFieldChange("devicesEnabled", checked)}
|
||||||
onFieldChange("devicesEnabled", checked)
|
/>
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("pages.config.monitor_usb")}
|
label={t("pages.config.monitor_usb")}
|
||||||
hint={t("pages.config.monitor_usb_hint")}
|
hint={t("pages.config.monitor_usb_hint")}
|
||||||
checked={form.monitorUSB}
|
layout="setting-row"
|
||||||
onCheckedChange={(checked) => onFieldChange("monitorUSB", checked)}
|
checked={form.monitorUSB}
|
||||||
/>
|
onCheckedChange={(checked) => onFieldChange("monitorUSB", checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("pages.config.autostart_label")}
|
label={t("pages.config.autostart_label")}
|
||||||
hint={autoStartHint}
|
hint={autoStartHint}
|
||||||
checked={autoStartEnabled}
|
layout="setting-row"
|
||||||
disabled={autoStartDisabled}
|
checked={autoStartEnabled}
|
||||||
onCheckedChange={onAutoStartChange}
|
disabled={autoStartDisabled}
|
||||||
/>
|
onCheckedChange={onAutoStartChange}
|
||||||
</div>
|
/>
|
||||||
</section>
|
</ConfigSectionCard>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdvancedSection() {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-3">
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{t("pages.config.advanced_desc")}
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link to="/config/raw">
|
|
||||||
<IconCode className="size-4" />
|
|
||||||
{t("pages.config.open_raw")}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
|
import { IconAdjustments } from "@tabler/icons-react"
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { Link } from "@tanstack/react-router"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -15,17 +18,9 @@ import {
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
export function RawJsonPanel() {
|
export function RawConfigPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
|
@ -124,81 +119,89 @@ export function RawJsonPanel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="flex h-full flex-col">
|
||||||
<CardHeader>
|
<PageHeader title={t("pages.config.raw_json_title")}>
|
||||||
<CardTitle>{t("pages.config.raw_json_title")}</CardTitle>
|
<Button variant="outline" asChild>
|
||||||
<CardDescription>{t("pages.config.raw_json_desc")}</CardDescription>
|
<Link to="/config">
|
||||||
</CardHeader>
|
<IconAdjustments className="size-4" />
|
||||||
<CardContent>
|
{t("pages.config.back_to_visual")}
|
||||||
{isLoading ? (
|
</Link>
|
||||||
<div className="flex h-64 items-center justify-center">
|
</Button>
|
||||||
<p>{t("labels.loading")}</p>
|
</PageHeader>
|
||||||
</div>
|
|
||||||
) : (
|
<div className="flex min-h-0 flex-1 flex-col p-1 lg:p-3 lg:p-6">
|
||||||
<div className="space-y-3">
|
<div className="mx-auto flex h-full min-h-0 w-full max-w-[1000px] flex-col">
|
||||||
{isDirty && (
|
{isLoading ? (
|
||||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
{t("pages.config.unsaved_changes")}
|
<p>{t("labels.loading")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
<div className="bg-muted/30 relative rounded-lg border">
|
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||||
<ScrollArea className="h-[calc(100vh-20rem)] min-h-[200px]">
|
{isDirty && (
|
||||||
|
<div className="shrink-0 rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
|
||||||
|
{t("pages.config.unsaved_changes")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg border shadow-sm">
|
||||||
<Textarea
|
<Textarea
|
||||||
value={effectiveEditorValue}
|
value={effectiveEditorValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setEditorValue(e.target.value)
|
setEditorValue(e.target.value)
|
||||||
setIsDirty(true)
|
setIsDirty(true)
|
||||||
}}
|
}}
|
||||||
className="min-h-[200px] resize-none border-0 bg-transparent px-4 py-3 font-mono text-sm shadow-none focus-visible:ring-0"
|
wrap="off"
|
||||||
|
className="h-full min-h-0 resize-none overflow-auto border-0 bg-transparent px-4 py-3 font-mono text-sm [overflow-wrap:normal] whitespace-pre shadow-none focus-visible:ring-0"
|
||||||
placeholder={t("pages.config.json_placeholder")}
|
placeholder={t("pages.config.json_placeholder")}
|
||||||
/>
|
/>
|
||||||
</ScrollArea>
|
</div>
|
||||||
|
<div className="flex shrink-0 justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleFormat}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{t("pages.config.format")}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog
|
||||||
|
open={showResetDialog}
|
||||||
|
onOpenChange={setShowResetDialog}
|
||||||
|
>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!isDirty}
|
||||||
|
onClick={() => setShowResetDialog(true)}
|
||||||
|
>
|
||||||
|
{t("common.reset")}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("pages.config.reset_confirm_title")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("pages.config.reset_confirm_desc")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmReset}>
|
||||||
|
{t("common.confirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<Button onClick={handleSave} disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? t("common.saving") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end space-x-2">
|
)}
|
||||||
<Button
|
</div>
|
||||||
variant="outline"
|
</div>
|
||||||
onClick={handleFormat}
|
</div>
|
||||||
disabled={mutation.isPending}
|
|
||||||
>
|
|
||||||
{t("pages.config.format")}
|
|
||||||
</Button>
|
|
||||||
<AlertDialog
|
|
||||||
open={showResetDialog}
|
|
||||||
onOpenChange={setShowResetDialog}
|
|
||||||
>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={!isDirty}
|
|
||||||
onClick={() => setShowResetDialog(true)}
|
|
||||||
>
|
|
||||||
{t("common.reset")}
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
{t("pages.config.reset_confirm_title")}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{t("pages.config.reset_confirm_desc")}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={confirmReset}>
|
|
||||||
{t("common.confirm")}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
<Button onClick={handleSave} disabled={mutation.isPending}>
|
|
||||||
{mutation.isPending ? t("common.saving") : t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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"
|
} from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type FieldLayout = "default" | "setting-row"
|
||||||
|
|
||||||
interface FieldProps {
|
interface FieldProps {
|
||||||
label: string
|
label: string
|
||||||
|
|
@ -16,9 +19,45 @@ interface FieldProps {
|
||||||
error?: string
|
error?: string
|
||||||
required?: boolean
|
required?: boolean
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
layout?: FieldLayout
|
||||||
|
controlClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Field({ label, hint, error, required, children }: FieldProps) {
|
export function Field({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
required,
|
||||||
|
children,
|
||||||
|
layout = "default",
|
||||||
|
controlClassName,
|
||||||
|
}: FieldProps) {
|
||||||
|
if (layout === "setting-row") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_minmax(240px,320px)] md:items-center md:gap-6">
|
||||||
|
<div className="max-w-full space-y-1 md:max-w-[clamp(18rem,42vw,28rem)]">
|
||||||
|
<FieldLabel>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</FieldLabel>
|
||||||
|
{hint && (
|
||||||
|
<FieldDescription className="text-xs leading-normal break-words">
|
||||||
|
{hint}
|
||||||
|
</FieldDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={cn("w-full md:justify-self-center", controlClassName)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<FieldDescription className="text-destructive text-xs leading-normal md:col-start-2">
|
||||||
|
{error}
|
||||||
|
</FieldDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UiField className="gap-2.5">
|
<UiField className="gap-2.5">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -85,6 +124,7 @@ interface SwitchCardFieldProps {
|
||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
|
layout?: FieldLayout
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SwitchCardField({
|
export function SwitchCardField({
|
||||||
|
|
@ -96,7 +136,37 @@ export function SwitchCardField({
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
disabled,
|
disabled,
|
||||||
children,
|
children,
|
||||||
|
layout = "default",
|
||||||
}: SwitchCardFieldProps) {
|
}: SwitchCardFieldProps) {
|
||||||
|
if (layout === "setting-row") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_auto] md:items-center md:gap-6">
|
||||||
|
<div className="max-w-full min-w-0 md:max-w-[clamp(18rem,42vw,28rem)]">
|
||||||
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
{hint && (
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs leading-normal break-words">
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center md:justify-self-center">
|
||||||
|
<Switch
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={onCheckedChange}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel ?? label}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{children && <div className="md:col-start-2">{children}</div>}
|
||||||
|
{error && (
|
||||||
|
<p className="text-destructive text-xs leading-normal md:col-start-2">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
|
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
|
|
||||||
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": {
|
"pages": {
|
||||||
"agent": {
|
"agent": {
|
||||||
"load_error": "Failed to load agent support information.",
|
"load_error": "Failed to load agent support information.",
|
||||||
"stats": {
|
|
||||||
"workspace": "Workspace",
|
|
||||||
"workspace_hint": "The default agent workspace used for runtime files and workspace skills.",
|
|
||||||
"skills": "Available Skills",
|
|
||||||
"skills_hint": "Skills discovered from workspace, global, and builtin roots.",
|
|
||||||
"tools": "Enabled Tools",
|
|
||||||
"tools_hint": "{{blocked}} blocked by missing dependencies."
|
|
||||||
},
|
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Skills",
|
|
||||||
"description": "Skills are loaded from the workspace, global PicoClaw home, and builtin directories.",
|
"description": "Skills are loaded from the workspace, global PicoClaw home, and builtin directories.",
|
||||||
"hero_title": "Skill Library",
|
|
||||||
"hero_description": "Browse every capability package the agent can load, then drill straight into the effective SKILL.md without leaving the page.",
|
|
||||||
"stats": {
|
|
||||||
"total": "Total Skills",
|
|
||||||
"workspace": "Workspace",
|
|
||||||
"shared": "Shared"
|
|
||||||
},
|
|
||||||
"empty": "No skills are currently available.",
|
"empty": "No skills are currently available.",
|
||||||
"import": "Import Skill",
|
"import": "Import Skill",
|
||||||
"import_title": "Import Skill",
|
|
||||||
"import_description": "Create a workspace skill by uploading a markdown file as the new SKILL.md.",
|
|
||||||
"import_name": "Skill Name",
|
|
||||||
"import_name_placeholder": "e.g. my-workflow",
|
|
||||||
"import_file": "Markdown File",
|
|
||||||
"import_file_hint": "Upload a .md file. The backend stores it as workspace/skills/<name>/SKILL.md.",
|
|
||||||
"import_confirm": "Import Skill",
|
|
||||||
"import_success": "Skill imported.",
|
"import_success": "Skill imported.",
|
||||||
"import_error": "Failed to import skill.",
|
"import_error": "Failed to import skill.",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
|
|
@ -371,28 +348,11 @@
|
||||||
"viewer_description": "Read the current effective SKILL.md content here.",
|
"viewer_description": "Read the current effective SKILL.md content here.",
|
||||||
"loading_detail": "Loading skill content...",
|
"loading_detail": "Loading skill content...",
|
||||||
"load_detail_error": "Failed to load skill content.",
|
"load_detail_error": "Failed to load skill content.",
|
||||||
"source": "Source",
|
|
||||||
"path": "Skill Path",
|
"path": "Skill Path",
|
||||||
"no_description": "No description provided.",
|
"no_description": "No description provided."
|
||||||
"sources": {
|
|
||||||
"workspace": "Workspace",
|
|
||||||
"global": "Global",
|
|
||||||
"builtin": "Builtin"
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"file_required": "Please choose a markdown file to import."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"title": "Tools",
|
|
||||||
"description": "This view reflects whether each agent tool is enabled, disabled, or blocked by a missing prerequisite.",
|
"description": "This view reflects whether each agent tool is enabled, disabled, or blocked by a missing prerequisite.",
|
||||||
"hero_title": "Tool Surface",
|
|
||||||
"hero_description": "Inspect what the agent can actually call right now, which capabilities are blocked, and where each tool is controlled in config.",
|
|
||||||
"stats": {
|
|
||||||
"enabled": "Enabled",
|
|
||||||
"blocked": "Blocked",
|
|
||||||
"categories": "Categories"
|
|
||||||
},
|
|
||||||
"empty": "No tools are available.",
|
"empty": "No tools are available.",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
|
|
@ -468,13 +428,15 @@
|
||||||
"allowed_cidrs": "Allowed Network CIDRs",
|
"allowed_cidrs": "Allowed Network CIDRs",
|
||||||
"allowed_cidrs_hint": "Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.",
|
"allowed_cidrs_hint": "Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.",
|
||||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||||
"launcher_load_error": "Failed to load service parameters.",
|
"sections": {
|
||||||
"launcher_restart_hint": "Service parameter changes apply after restarting PicoClaw Web.",
|
"agent": "Agent",
|
||||||
"advanced_desc": "Open the raw JSON page to edit every field directly.",
|
"runtime": "Runtime",
|
||||||
|
"launcher": "Service",
|
||||||
|
"devices": "Devices"
|
||||||
|
},
|
||||||
"open_raw": "Raw Config",
|
"open_raw": "Raw Config",
|
||||||
"back_to_visual": "Visual Config",
|
"back_to_visual": "Visual Config",
|
||||||
"raw_json_title": "Raw JSON Configuration",
|
"raw_json_title": "Raw JSON Configuration",
|
||||||
"raw_json_desc": "Advanced users can directly edit the raw JSON configuration below.",
|
|
||||||
"json_placeholder": "Enter valid JSON configuration...",
|
"json_placeholder": "Enter valid JSON configuration...",
|
||||||
"save_success": "Configuration saved successfully.",
|
"save_success": "Configuration saved successfully.",
|
||||||
"save_error": "Failed to save configuration.",
|
"save_error": "Failed to save configuration.",
|
||||||
|
|
@ -488,7 +450,6 @@
|
||||||
"unsaved_changes": "You have unsaved changes."
|
"unsaved_changes": "You have unsaved changes."
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"description": "System logs and monitoring.",
|
|
||||||
"clear": "Clear logs",
|
"clear": "Clear logs",
|
||||||
"empty": "Waiting for logs..."
|
"empty": "Waiting for logs..."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -331,33 +331,10 @@
|
||||||
"pages": {
|
"pages": {
|
||||||
"agent": {
|
"agent": {
|
||||||
"load_error": "加载 Agent 支持信息失败。",
|
"load_error": "加载 Agent 支持信息失败。",
|
||||||
"stats": {
|
|
||||||
"workspace": "工作目录",
|
|
||||||
"workspace_hint": "默认 Agent 运行时使用的工作目录,也用于加载工作区技能。",
|
|
||||||
"skills": "可用技能数",
|
|
||||||
"skills_hint": "从工作区、全局目录和内置目录发现的技能。",
|
|
||||||
"tools": "已启用工具",
|
|
||||||
"tools_hint": "其中 {{blocked}} 个因依赖未满足而不可用。"
|
|
||||||
},
|
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "技能",
|
|
||||||
"description": "技能会从工作区、PicoClaw 全局目录和内置目录中加载。",
|
"description": "技能会从工作区、PicoClaw 全局目录和内置目录中加载。",
|
||||||
"hero_title": "技能库",
|
|
||||||
"hero_description": "在这里查看 Agent 当前可加载的能力包,并且不离开页面就能直接阅读生效后的 SKILL.md。",
|
|
||||||
"stats": {
|
|
||||||
"total": "技能总数",
|
|
||||||
"workspace": "工作区技能",
|
|
||||||
"shared": "共享技能"
|
|
||||||
},
|
|
||||||
"empty": "当前没有可用技能。",
|
"empty": "当前没有可用技能。",
|
||||||
"import": "导入技能",
|
"import": "导入技能",
|
||||||
"import_title": "导入技能",
|
|
||||||
"import_description": "通过上传 Markdown 文件创建工作区技能,文件会保存为新的 SKILL.md。",
|
|
||||||
"import_name": "技能名称",
|
|
||||||
"import_name_placeholder": "例如 my-workflow",
|
|
||||||
"import_file": "Markdown 文件",
|
|
||||||
"import_file_hint": "上传一个 .md 文件。后端会保存到 workspace/skills/<name>/SKILL.md。",
|
|
||||||
"import_confirm": "导入技能",
|
|
||||||
"import_success": "技能导入成功。",
|
"import_success": "技能导入成功。",
|
||||||
"import_error": "导入技能失败。",
|
"import_error": "导入技能失败。",
|
||||||
"view": "查看",
|
"view": "查看",
|
||||||
|
|
@ -371,28 +348,11 @@
|
||||||
"viewer_description": "这里展示当前生效的 SKILL.md 内容。",
|
"viewer_description": "这里展示当前生效的 SKILL.md 内容。",
|
||||||
"loading_detail": "正在加载技能内容...",
|
"loading_detail": "正在加载技能内容...",
|
||||||
"load_detail_error": "加载技能内容失败。",
|
"load_detail_error": "加载技能内容失败。",
|
||||||
"source": "来源",
|
|
||||||
"path": "技能路径",
|
"path": "技能路径",
|
||||||
"no_description": "未提供描述。",
|
"no_description": "未提供描述。"
|
||||||
"sources": {
|
|
||||||
"workspace": "工作区",
|
|
||||||
"global": "全局",
|
|
||||||
"builtin": "内置"
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"file_required": "请先选择要导入的 Markdown 文件。"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"title": "工具",
|
|
||||||
"description": "这里展示每个 Agent 工具当前是已启用、已禁用,还是被依赖条件阻塞。",
|
"description": "这里展示每个 Agent 工具当前是已启用、已禁用,还是被依赖条件阻塞。",
|
||||||
"hero_title": "工具面板",
|
|
||||||
"hero_description": "集中查看 Agent 现在真正可调用的工具、被阻塞的能力,以及它们分别受哪项配置控制。",
|
|
||||||
"stats": {
|
|
||||||
"enabled": "已启用",
|
|
||||||
"blocked": "被阻塞",
|
|
||||||
"categories": "分类数"
|
|
||||||
},
|
|
||||||
"empty": "当前没有可用工具。",
|
"empty": "当前没有可用工具。",
|
||||||
"enable": "启用",
|
"enable": "启用",
|
||||||
"disable": "禁用",
|
"disable": "禁用",
|
||||||
|
|
@ -465,16 +425,18 @@
|
||||||
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口。",
|
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口。",
|
||||||
"lan_access": "启用局域网访问",
|
"lan_access": "启用局域网访问",
|
||||||
"lan_access_hint": "允许局域网中的其他设备访问当前服务。",
|
"lan_access_hint": "允许局域网中的其他设备访问当前服务。",
|
||||||
"allowed_cidrs": "允许访问网段(CIDR)",
|
"allowed_cidrs": "允许访问网段",
|
||||||
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。",
|
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。",
|
||||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||||
"launcher_load_error": "加载服务参数失败。",
|
"sections": {
|
||||||
"launcher_restart_hint": "服务参数变更需重启 PicoClaw Web 后生效。",
|
"agent": "智能体",
|
||||||
"advanced_desc": "可打开原始 JSON 页面直接编辑全部字段。",
|
"runtime": "运行时",
|
||||||
|
"launcher": "服务参数",
|
||||||
|
"devices": "设备"
|
||||||
|
},
|
||||||
"open_raw": "原始配置",
|
"open_raw": "原始配置",
|
||||||
"back_to_visual": "可视化配置",
|
"back_to_visual": "可视化配置",
|
||||||
"raw_json_title": "原始 JSON 配置",
|
"raw_json_title": "原始 JSON 配置",
|
||||||
"raw_json_desc": "高级用户可以直接编辑下方的原始 JSON 配置。",
|
|
||||||
"json_placeholder": "请输入有效的 JSON 配置...",
|
"json_placeholder": "请输入有效的 JSON 配置...",
|
||||||
"save_success": "配置保存成功。",
|
"save_success": "配置保存成功。",
|
||||||
"save_error": "配置保存失败。",
|
"save_error": "配置保存失败。",
|
||||||
|
|
@ -488,7 +450,6 @@
|
||||||
"unsaved_changes": "您有未保存的更改。"
|
"unsaved_changes": "您有未保存的更改。"
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"description": "系统日志和监控。",
|
|
||||||
"clear": "清空日志",
|
"clear": "清空日志",
|
||||||
"empty": "等待日志中..."
|
"empty": "等待日志中..."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { createFileRoute } from "@tanstack/react-router"
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router"
|
|
||||||
import { useTranslation } from "react-i18next"
|
|
||||||
|
|
||||||
import { RawJsonPanel } from "@/components/config/raw-json-panel"
|
import { RawConfigPage } from "@/components/config/raw-config-page"
|
||||||
import { PageHeader } from "@/components/page-header"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/config/raw")({
|
export const Route = createFileRoute("/config/raw")({
|
||||||
component: RawConfigPage,
|
component: RawConfigPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function RawConfigPage() {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<PageHeader title={t("pages.config.raw_json_title")}>
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link to="/config">
|
|
||||||
<IconAdjustments className="size-4" />
|
|
||||||
{t("pages.config.back_to_visual")}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-3 lg:p-6">
|
|
||||||
<div className="mx-auto max-w-4xl">
|
|
||||||
<RawJsonPanel />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,156 +1,7 @@
|
||||||
import { IconTrash } from "@tabler/icons-react"
|
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { useAtomValue } from "jotai"
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import { useTranslation } from "react-i18next"
|
|
||||||
|
|
||||||
import { clearGatewayLogs, getGatewayStatus } from "@/api/gateway"
|
import { LogsPage } from "@/components/logs/logs-page"
|
||||||
import { PageHeader } from "@/components/page-header"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import { gatewayAtom } from "@/store/gateway"
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/logs")({
|
export const Route = createFileRoute("/logs")({
|
||||||
component: LogsPage,
|
component: LogsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function LogsPage() {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const [logs, setLogs] = useState<string[]>([])
|
|
||||||
const [clearing, setClearing] = useState(false)
|
|
||||||
const logOffsetRef = useRef<number>(0)
|
|
||||||
const logRunIdRef = useRef<number>(-1)
|
|
||||||
const syncTokenRef = useRef<number>(0)
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const gateway = useAtomValue(gatewayAtom)
|
|
||||||
|
|
||||||
const handleClearLogs = async () => {
|
|
||||||
setClearing(true)
|
|
||||||
try {
|
|
||||||
const data = await clearGatewayLogs()
|
|
||||||
syncTokenRef.current += 1
|
|
||||||
setLogs([])
|
|
||||||
logOffsetRef.current = data.log_total ?? 0
|
|
||||||
if (data.log_run_id !== undefined) {
|
|
||||||
logRunIdRef.current = data.log_run_id
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore clear failures silently to avoid noisy transient errors.
|
|
||||||
} finally {
|
|
||||||
setClearing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true
|
|
||||||
let timeout: ReturnType<typeof setTimeout>
|
|
||||||
|
|
||||||
const fetchLogs = async () => {
|
|
||||||
// Only fetch logs if the gateway is running or starting
|
|
||||||
if (
|
|
||||||
!mounted ||
|
|
||||||
(gateway.status !== "running" && gateway.status !== "starting")
|
|
||||||
) {
|
|
||||||
if (mounted) {
|
|
||||||
// Still poll the state, but maybe at a slower rate, or we just rely on SSE for status
|
|
||||||
// and restart fast polling when it's running. Let's just re-evaluate every second
|
|
||||||
timeout = setTimeout(fetchLogs, 1000)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const requestToken = syncTokenRef.current
|
|
||||||
const requestOffset = logOffsetRef.current
|
|
||||||
const requestRunId = logRunIdRef.current
|
|
||||||
const data = await getGatewayStatus({
|
|
||||||
log_offset: requestOffset,
|
|
||||||
log_run_id: requestRunId,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!mounted || requestToken !== syncTokenRef.current) return
|
|
||||||
|
|
||||||
if (data.log_run_id !== undefined && data.log_run_id !== requestRunId) {
|
|
||||||
logRunIdRef.current = data.log_run_id
|
|
||||||
logOffsetRef.current = 0
|
|
||||||
if (data.logs) {
|
|
||||||
setLogs(data.logs)
|
|
||||||
logOffsetRef.current = data.log_total || data.logs.length
|
|
||||||
}
|
|
||||||
} else if (data.logs && data.logs.length > 0) {
|
|
||||||
setLogs((prev) => [...prev, ...data.logs!])
|
|
||||||
logOffsetRef.current =
|
|
||||||
data.log_total || logOffsetRef.current + data.logs.length
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore simple fetch errors during polling
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
timeout = setTimeout(fetchLogs, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchLogs()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false
|
|
||||||
clearTimeout(timeout)
|
|
||||||
}
|
|
||||||
}, [gateway.status])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollIntoView({ behavior: "smooth" })
|
|
||||||
}
|
|
||||||
}, [logs])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<PageHeader title={t("navigation.logs")} />
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden p-4 sm:p-8">
|
|
||||||
<div className="mb-4 flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
|
||||||
{t("navigation.logs")}
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
{t("pages.logs.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClearLogs}
|
|
||||||
disabled={logs.length === 0 || clearing}
|
|
||||||
>
|
|
||||||
<IconTrash className="size-4" />
|
|
||||||
{t("pages.logs.clear")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-muted/30 relative flex-1 overflow-hidden rounded-lg border">
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<div className="p-4 font-mono text-sm leading-relaxed">
|
|
||||||
{logs.length === 0 ? (
|
|
||||||
<div className="text-muted-foreground italic">
|
|
||||||
{t("pages.logs.empty")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
logs.map((log, i) => (
|
|
||||||
<div key={i} className="break-all whitespace-pre-wrap">
|
|
||||||
{log}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
<div ref={scrollRef} />
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ Default model is `google/gemini-3-flash-preview` if none is set.
|
||||||
Optional config file: `~/.summarize/config.json`
|
Optional config file: `~/.summarize/config.json`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "model": "openai/gpt-5.2" }
|
{ "model": "openai/gpt-5.4" }
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional services:
|
Optional services:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue