Merge branch 'main' of github.com:sipeed/picoclaw
# Conflicts: # workspace/AGENTS.md
This commit is contained in:
commit
6e7234bf5d
765 changed files with 137883 additions and 20531 deletions
12
.env.example
12
.env.example
|
|
@ -5,16 +5,18 @@
|
|||
# ANTHROPIC_API_KEY=sk-ant-xxx
|
||||
# OPENAI_API_KEY=sk-xxx
|
||||
# GEMINI_API_KEY=xxx
|
||||
# CEREBRAS_API_KEY=xxx
|
||||
|
||||
# MODELSCOPE_API_KEY=xxx
|
||||
# CLAUDE_CODE_OAUTH=xxx
|
||||
# ── Chat Channel ──────────────────────────
|
||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||
# DISCORD_BOT_TOKEN=xxx
|
||||
# LINE_CHANNEL_SECRET=xxx
|
||||
# LINE_CHANNEL_ACCESS_TOKEN=xxx
|
||||
# Feishu (飞书)
|
||||
# PICOCLAW_CHANNELS_FEISHU_APP_ID=cli_xxx
|
||||
# PICOCLAW_CHANNELS_FEISHU_APP_SECRET=xxx
|
||||
# PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI=Typing,OneSecond
|
||||
|
||||
# ── Web Search (optional) ────────────────
|
||||
# BRAVE_SEARCH_API_KEY=BSA...
|
||||
|
||||
# ── Timezone ──────────────────────────────
|
||||
TZ=Asia/Tokyo
|
||||
TZ=Asia/Shanghai
|
||||
|
|
|
|||
27
.github/dependabot.yml
vendored
Normal file
27
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
version: 2
|
||||
|
||||
updates:
|
||||
|
||||
# Go dependencies (entire repo)
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "go"
|
||||
|
||||
# Frontend dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/web/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "frontend"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
8
.github/workflows/docker-build.yml
vendored
8
.github/workflows/docker-build.yml
vendored
|
|
@ -31,11 +31,11 @@ jobs:
|
|||
|
||||
# ── Docker Buildx ─────────────────────────
|
||||
- name: 🔧 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# ── Login to GHCR ─────────────────────────
|
||||
- name: 🔑 Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
|
||||
# ── Login to Docker Hub ────────────────────
|
||||
- name: 🔑 Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
|
@ -62,7 +62,7 @@ jobs:
|
|||
|
||||
# ── Build & Push ──────────────────────────
|
||||
- name: 🚀 Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
|
|
|||
138
.github/workflows/nightly.yml
vendored
Normal file
138
.github/workflows/nightly.yml
vendored
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
name: Nightly Build
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
name: Nightly Build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute version
|
||||
id: version
|
||||
run: |
|
||||
DATE=$(date -u +%Y%m%d)
|
||||
SHA=$(git rev-parse --short=8 HEAD)
|
||||
BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true)
|
||||
if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then
|
||||
VERSION="v0.0.0-nightly.${DATE}.${SHA}"
|
||||
else
|
||||
VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}"
|
||||
fi
|
||||
|
||||
COMPARE_URL="https://github.com/${{ github.repository }}/commits/main"
|
||||
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then
|
||||
COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...main"
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Go from go.mod
|
||||
id: setup-go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create local tag for GoReleaser
|
||||
run: git tag "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: ~> v2
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
|
||||
NIGHTLY_BUILD: "true"
|
||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||
|
||||
- name: Update nightly release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
CHANGELOG='${{ steps.version.outputs.changelog }}'
|
||||
NOTES=$(cat <<EOF
|
||||
Nightly build for **${VERSION}**
|
||||
|
||||
This is an automated build and may be unstable. Use with caution.
|
||||
|
||||
${CHANGELOG}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Delete existing nightly release and tag
|
||||
gh release delete nightly --cleanup-tag -y 2>/dev/null || true
|
||||
|
||||
# Force-update nightly tag to current HEAD
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -fa nightly -m "Nightly build ${VERSION}"
|
||||
git push origin nightly
|
||||
|
||||
# Collect release artifacts from goreleaser dist/
|
||||
ASSETS=()
|
||||
for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do
|
||||
[ -f "$f" ] && ASSETS+=("$f")
|
||||
done
|
||||
|
||||
# Create nightly release (prerelease, NOT latest)
|
||||
gh release create nightly \
|
||||
--title "Nightly Build" \
|
||||
--notes "$NOTES" \
|
||||
--target "${{ github.sha }}" \
|
||||
--prerelease \
|
||||
--latest=false \
|
||||
"${ASSETS[@]}"
|
||||
|
||||
24
.github/workflows/pr.yml
vendored
24
.github/workflows/pr.yml
vendored
|
|
@ -23,6 +23,28 @@ jobs:
|
|||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.10.1
|
||||
args: --build-tags=goolm,stdjson
|
||||
|
||||
vuln_check:
|
||||
name: Security Check
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOFLAGS: -tags=goolm,stdjson
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run Govulncheck
|
||||
uses: golang/govulncheck-action@v1
|
||||
with:
|
||||
go-package: ./...
|
||||
|
||||
test:
|
||||
name: Tests
|
||||
|
|
@ -40,4 +62,4 @@ jobs:
|
|||
run: go generate ./...
|
||||
|
||||
- name: Run go test
|
||||
run: go test ./...
|
||||
run: go test -tags goolm,stdjson ./...
|
||||
|
|
|
|||
37
.github/workflows/release.yml
vendored
37
.github/workflows/release.yml
vendored
|
|
@ -17,6 +17,11 @@ on:
|
|||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
upload_tos:
|
||||
description: "Upload to Volcengine TOS"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
create-tag:
|
||||
|
|
@ -60,28 +65,36 @@ jobs:
|
|||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: ~> v2
|
||||
|
|
@ -91,6 +104,11 @@ jobs:
|
|||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||
|
||||
- name: Apply release flags
|
||||
shell: bash
|
||||
|
|
@ -100,3 +118,12 @@ jobs:
|
|||
gh release edit "${{ inputs.tag }}" \
|
||||
--draft=${{ inputs.draft }} \
|
||||
--prerelease=${{ inputs.prerelease }}
|
||||
|
||||
upload-tos:
|
||||
name: Upload to TOS
|
||||
needs: release
|
||||
if: ${{ inputs.upload_tos }}
|
||||
uses: ./.github/workflows/upload-tos.yml
|
||||
with:
|
||||
tag: ${{ inputs.tag }}
|
||||
secrets: inherit
|
||||
|
|
|
|||
49
.github/workflows/upload-tos.yml
vendored
Normal file
49
.github/workflows/upload-tos.yml
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
name: Upload to Volcengine TOS
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to download and upload (e.g. v0.2.0)"
|
||||
required: true
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to download and upload"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
upload-tos:
|
||||
name: Upload to Volcengine TOS
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
gh release download "${{ inputs.tag }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir artifacts \
|
||||
--pattern "*.tar.gz" \
|
||||
--pattern "*.zip" \
|
||||
--pattern "*.rpm" \
|
||||
--pattern "*.deb"
|
||||
|
||||
- name: Upload to Volcengine TOS
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.VOLC_TOS_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.VOLC_TOS_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: cn-beijing
|
||||
run: |
|
||||
aws configure set default.s3.addressing_style virtual
|
||||
TOS_ENDPOINT="https://tos-s3-cn-beijing.volces.com"
|
||||
# Upload to versioned directory
|
||||
aws s3 sync artifacts/ "s3://picoclaw-downloads/${{ inputs.tag }}/" \
|
||||
--endpoint-url "$TOS_ENDPOINT"
|
||||
# Upload to latest (overwrite)
|
||||
aws s3 sync artifacts/ "s3://picoclaw-downloads/latest/" \
|
||||
--endpoint-url "$TOS_ENDPOINT" \
|
||||
--delete
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
|
|
@ -38,12 +38,29 @@ ralph/
|
|||
.ralph/
|
||||
tasks/
|
||||
|
||||
# Plans
|
||||
docs/plans/
|
||||
docs/superpowers/
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Added by goreleaser init:
|
||||
dist/
|
||||
*.vite/
|
||||
|
||||
# Windows Application Icon/Resource
|
||||
*.syso
|
||||
|
||||
# Test telegram integration
|
||||
cmd/telegram/
|
||||
|
||||
# Keep embedded backend dist directory placeholder in VCS
|
||||
!web/backend/dist/
|
||||
web/backend/dist/*
|
||||
!web/backend/dist/.gitkeep
|
||||
|
||||
.claude/
|
||||
|
||||
docker/data
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ linters:
|
|||
- containedctx
|
||||
- cyclop
|
||||
- depguard
|
||||
- dupl
|
||||
- dupword
|
||||
- err113
|
||||
- exhaustruct
|
||||
|
|
|
|||
|
|
@ -6,45 +6,59 @@ before:
|
|||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./...
|
||||
- sh -c 'cd web/frontend && pnpm install && pnpm build:backend'
|
||||
- go install github.com/tc-hib/go-winres@latest
|
||||
- go-winres make --in cmd/picoclaw-launcher/winres/winres.json --out cmd/picoclaw-launcher/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
||||
- go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
||||
|
||||
builds:
|
||||
- id: picoclaw
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
tags:
|
||||
- goolm
|
||||
- stdjson
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }}
|
||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }}
|
||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }}
|
||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }}
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
- freebsd
|
||||
- netbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
- loong64
|
||||
- arm
|
||||
- s390x
|
||||
- mipsle
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
gomips:
|
||||
- softfloat
|
||||
main: ./cmd/picoclaw
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: netbsd
|
||||
goarch: s390x
|
||||
- goos: netbsd
|
||||
goarch: mips64
|
||||
- goos: netbsd
|
||||
goarch: arm
|
||||
|
||||
- id: picoclaw-launcher
|
||||
binary: picoclaw-launcher
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
tags:
|
||||
- goolm
|
||||
- stdjson
|
||||
ldflags:
|
||||
- -s -w
|
||||
|
|
@ -53,25 +67,37 @@ builds:
|
|||
- windows
|
||||
- darwin
|
||||
- freebsd
|
||||
- netbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
- loong64
|
||||
- arm
|
||||
- s390x
|
||||
- mipsle
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
main: ./cmd/picoclaw-launcher
|
||||
gomips:
|
||||
- softfloat
|
||||
main: ./web/backend
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: netbsd
|
||||
goarch: s390x
|
||||
- goos: netbsd
|
||||
goarch: mips64
|
||||
- goos: netbsd
|
||||
goarch: arm
|
||||
|
||||
- id: picoclaw-launcher-tui
|
||||
binary: picoclaw-launcher-tui
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
tags:
|
||||
- goolm
|
||||
- stdjson
|
||||
ldflags:
|
||||
- -s -w
|
||||
|
|
@ -80,19 +106,30 @@ builds:
|
|||
- windows
|
||||
- darwin
|
||||
- freebsd
|
||||
- netbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
- loong64
|
||||
- arm
|
||||
- s390x
|
||||
- mipsle
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
gomips:
|
||||
- softfloat
|
||||
main: ./cmd/picoclaw-launcher-tui
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: netbsd
|
||||
goarch: s390x
|
||||
- goos: netbsd
|
||||
goarch: mips64
|
||||
- goos: netbsd
|
||||
goarch: arm
|
||||
|
||||
dockers_v2:
|
||||
- id: picoclaw
|
||||
|
|
@ -103,15 +140,49 @@ dockers_v2:
|
|||
- picoclaw
|
||||
images:
|
||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||
- "docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}"
|
||||
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
|
||||
tags:
|
||||
- "{{ .Tag }}"
|
||||
- "latest"
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}'
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/riscv64
|
||||
|
||||
- id: picoclaw-launcher
|
||||
dockerfile: docker/Dockerfile.goreleaser.launcher
|
||||
ids:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
- picoclaw-launcher-tui
|
||||
images:
|
||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
|
||||
tags:
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}'
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}'
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/riscv64
|
||||
|
||||
notarize:
|
||||
macos:
|
||||
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
|
||||
ids:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
- picoclaw-launcher-tui
|
||||
sign:
|
||||
certificate: "{{.Env.MACOS_SIGN_P12}}"
|
||||
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
|
||||
notarize:
|
||||
issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}"
|
||||
key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}"
|
||||
key: "{{.Env.MACOS_NOTARY_KEY}}"
|
||||
wait: true
|
||||
timeout: 20m
|
||||
|
||||
archives:
|
||||
- formats: [tar.gz]
|
||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||
|
|
@ -129,7 +200,7 @@ archives:
|
|||
|
||||
nfpms:
|
||||
- id: picoclaw
|
||||
builds:
|
||||
ids:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
- picoclaw-launcher-tui
|
||||
|
|
@ -149,6 +220,11 @@ nfpms:
|
|||
- rpm
|
||||
- deb
|
||||
bindir: /usr/bin
|
||||
contents:
|
||||
- src: web/picoclaw-launcher.desktop
|
||||
dst: /usr/share/applications/picoclaw-launcher.desktop
|
||||
- src: web/picoclaw-launcher.png
|
||||
dst: /usr/share/icons/hicolor/512x512/apps/picoclaw-launcher.png
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
|
|
@ -163,6 +239,7 @@ changelog:
|
|||
# lzma: true
|
||||
|
||||
release:
|
||||
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
|
||||
footer: >-
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -269,8 +269,8 @@ Once your PR is submitted, you can reach out to the assigned reviewers listed in
|
|||
|Function| Reviewer|
|
||||
|--- |--- |
|
||||
|Provider|@yinwm |
|
||||
|Channel |@yinwm |
|
||||
|Agent |@lxowalle|
|
||||
|Channel |@yinwm/@alexhoshina |
|
||||
|Agent |@lxowalle/@Zhaoyikaiii|
|
||||
|Tools |@lxowalle|
|
||||
|SKill ||
|
||||
|MCP ||
|
||||
|
|
|
|||
|
|
@ -268,8 +268,8 @@ Release 分支的保护级别高于 `main`,在任何情况下均不允许直
|
|||
|Function| Reviewer|
|
||||
|--- |--- |
|
||||
|Provider|@yinwm |
|
||||
|Channel |@yinwm |
|
||||
|Agent |@lxowalle|
|
||||
|Channel |@yinwm/@alexhoshina |
|
||||
|Agent |@lxowalle/@Zhaoyikaiii|
|
||||
|Tools |@lxowalle|
|
||||
|SKill ||
|
||||
|MCP ||
|
||||
|
|
|
|||
4
LICENSE
4
LICENSE
|
|
@ -19,7 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
PicoClaw is heavily inspired by and based on [nanobot](https://github.com/HKUDS/nanobot) by HKUDS.
|
||||
|
|
|
|||
169
Makefile
169
Makefile
|
|
@ -11,12 +11,41 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
|||
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||
BUILD_TIME=$(shell date +%FT%T%z)
|
||||
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
||||
INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal
|
||||
LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w"
|
||||
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
|
||||
|
||||
# Go variables
|
||||
GO?=CGO_ENABLED=0 go
|
||||
GOFLAGS?=-v -tags stdjson
|
||||
WEB_GO?=$(GO)
|
||||
GO_BUILD_TAGS?=goolm,stdjson
|
||||
GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
|
||||
comma:=,
|
||||
empty:=
|
||||
space:=$(empty) $(empty)
|
||||
GO_BUILD_TAGS_NO_GOOLM:=$(subst $(space),$(comma),$(strip $(filter-out goolm,$(subst $(comma),$(space),$(GO_BUILD_TAGS)))))
|
||||
GOFLAGS_NO_GOOLM?=-v -tags $(GO_BUILD_TAGS_NO_GOOLM)
|
||||
|
||||
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
|
||||
#
|
||||
# Bytes (octal): \004 \024 \000 \160 → little-endian 0x70001404
|
||||
# 0x70000000 EF_MIPS_ARCH_32R2 MIPS32 Release 2
|
||||
# 0x00001000 EF_MIPS_ABI_O32 O32 ABI
|
||||
# 0x00000400 EF_MIPS_NAN2008 IEEE 754-2008 NaN encoding
|
||||
# 0x00000004 EF_MIPS_CPIC PIC calling sequence
|
||||
#
|
||||
# Go's GOMIPS=softfloat emits no FP instructions, so the NaN mode is irrelevant
|
||||
# at runtime — this is purely an ELF metadata fix to satisfy the kernel's check.
|
||||
# patchelf cannot modify e_flags; dd at a fixed offset is the most portable way.
|
||||
#
|
||||
# Ref: https://codebrowser.dev/linux/linux/arch/mips/include/asm/elf.h.html
|
||||
define PATCH_MIPS_FLAGS
|
||||
@if [ -f "$(1)" ]; then \
|
||||
printf '\004\024\000\160' | dd of=$(1) bs=1 seek=36 count=4 conv=notrunc 2>/dev/null || \
|
||||
{ echo "Error: failed to patch MIPS e_flags for $(1)"; exit 1; }; \
|
||||
else \
|
||||
echo "Error: $(1) not found, cannot patch MIPS e_flags"; exit 1; \
|
||||
fi
|
||||
endef
|
||||
|
||||
# Golangci-lint
|
||||
GOLANGCI_LINT?=golangci-lint
|
||||
|
|
@ -50,11 +79,14 @@ ifeq ($(UNAME_S),Linux)
|
|||
ARCH=loong64
|
||||
else ifeq ($(UNAME_M),riscv64)
|
||||
ARCH=riscv64
|
||||
else ifeq ($(UNAME_M),mipsel)
|
||||
ARCH=mipsle
|
||||
else
|
||||
ARCH=$(UNAME_M)
|
||||
endif
|
||||
else ifeq ($(UNAME_S),Darwin)
|
||||
PLATFORM=darwin
|
||||
WEB_GO=CGO_ENABLED=1 go
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
ARCH=amd64
|
||||
else ifeq ($(UNAME_M),arm64)
|
||||
|
|
@ -83,23 +115,37 @@ generate:
|
|||
build: generate
|
||||
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BINARY_PATH)"
|
||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||
|
||||
## build-launcher: Build the picoclaw-launcher (web console) binary
|
||||
build-launcher:
|
||||
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@if [ ! -f web/backend/dist/index.html ]; then \
|
||||
echo "Building frontend..."; \
|
||||
cd web/frontend && pnpm install && pnpm build:backend; \
|
||||
fi
|
||||
@$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
|
||||
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
||||
|
||||
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||
build-whatsapp-native: generate
|
||||
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||
@echo "Building for multiple platforms..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||
## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=loong64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=riscv64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags $(GO_BUILD_TAGS_NO_GOOLM),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||
GOOS=darwin GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||
## @$(GO) build $(GOFLAGS) -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||
@echo "Build complete"
|
||||
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||
|
||||
|
|
@ -107,16 +153,24 @@ build-whatsapp-native: generate
|
|||
build-linux-arm: generate
|
||||
@echo "Building for linux/arm (GOARM=7)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
|
||||
|
||||
## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
|
||||
build-linux-arm64: generate
|
||||
@echo "Building for linux/arm64..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
||||
|
||||
## build-linux-mipsle: Build for Linux MIPS32 LE
|
||||
build-linux-mipsle: generate
|
||||
@echo "Building for linux/mipsle (softfloat)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
|
||||
|
||||
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
|
||||
build-pi-zero: build-linux-arm build-linux-arm64
|
||||
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
|
||||
|
|
@ -125,14 +179,18 @@ build-pi-zero: build-linux-arm build-linux-arm64
|
|||
build-all: generate
|
||||
@echo "Building for multiple platforms..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
||||
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=loong64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=riscv64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
||||
GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||
GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
|
||||
GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
|
||||
@echo "All builds complete"
|
||||
|
||||
## install: Install picoclaw to system and copy builtin skills
|
||||
|
|
@ -168,12 +226,15 @@ clean:
|
|||
@echo "Clean complete"
|
||||
|
||||
## vet: Run go vet for static analysis
|
||||
vet:
|
||||
@$(GO) vet ./...
|
||||
vet: generate
|
||||
@packages="$$($(GO) list $(GOFLAGS) ./...)" && \
|
||||
$(GO) vet $(GOFLAGS) $$(printf '%s\n' "$$packages" | grep -v '^github.com/sipeed/picoclaw/web/')
|
||||
@cd web/backend && $(WEB_GO) vet ./...
|
||||
|
||||
## test: Test Go code
|
||||
test:
|
||||
@$(GO) test ./...
|
||||
test: generate
|
||||
@$(GO) test $(GOFLAGS) $$($(GO) list $(GOFLAGS) ./... | grep -v github.com/sipeed/picoclaw/web/)
|
||||
@cd web && make test
|
||||
|
||||
## fmt: Format Go code
|
||||
fmt:
|
||||
|
|
@ -181,11 +242,11 @@ fmt:
|
|||
|
||||
## lint: Run linters
|
||||
lint:
|
||||
@$(GOLANGCI_LINT) run
|
||||
@CGO_ENABLED=0 $(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS)
|
||||
|
||||
## fix: Fix linting issues
|
||||
fix:
|
||||
@$(GOLANGCI_LINT) run --fix
|
||||
@CGO_ENABLED=0 $(GOLANGCI_LINT) run --fix --build-tags $(GO_BUILD_TAGS)
|
||||
|
||||
## deps: Download dependencies
|
||||
deps:
|
||||
|
|
@ -204,6 +265,56 @@ check: deps fmt vet test
|
|||
run: build
|
||||
@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
|
||||
|
||||
## docker-build: Build Docker image (minimal Alpine-based)
|
||||
docker-build:
|
||||
@echo "Building minimal Docker image (Alpine-based)..."
|
||||
docker compose -f docker/docker-compose.yml build picoclaw-agent picoclaw-gateway
|
||||
|
||||
## docker-build-full: Build Docker image with full MCP support (Node.js 24)
|
||||
docker-build-full:
|
||||
@echo "Building full-featured Docker image (Node.js 24)..."
|
||||
docker compose -f docker/docker-compose.full.yml build picoclaw-agent picoclaw-gateway
|
||||
|
||||
## docker-test: Test MCP tools in Docker container
|
||||
docker-test:
|
||||
@echo "Testing MCP tools in Docker..."
|
||||
@chmod +x scripts/test-docker-mcp.sh
|
||||
@./scripts/test-docker-mcp.sh
|
||||
|
||||
## docker-run: Run picoclaw gateway in Docker (Alpine-based)
|
||||
docker-run:
|
||||
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||
|
||||
## docker-run-full: Run picoclaw gateway in Docker (full-featured)
|
||||
docker-run-full:
|
||||
docker compose -f docker/docker-compose.full.yml --profile gateway up
|
||||
|
||||
## docker-run-agent: Run picoclaw agent in Docker (interactive, Alpine-based)
|
||||
docker-run-agent:
|
||||
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||
|
||||
## docker-run-agent-full: Run picoclaw agent in Docker (interactive, full-featured)
|
||||
docker-run-agent-full:
|
||||
docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent
|
||||
|
||||
## docker-clean: Clean Docker images and volumes
|
||||
docker-clean:
|
||||
docker compose -f docker/docker-compose.yml down -v
|
||||
docker compose -f docker/docker-compose.full.yml down -v
|
||||
docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true
|
||||
|
||||
|
||||
## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window)
|
||||
build-macos-app:
|
||||
@echo "Building macOS .app bundle..."
|
||||
@if [ "$(UNAME_S)" != "Darwin" ]; then \
|
||||
echo "Error: This target is only available on macOS"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@cd web && $(MAKE) build && cd ..
|
||||
@./scripts/build-macos-app.sh $(BINARY_NAME)-$(PLATFORM)-$(ARCH)
|
||||
@echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app"
|
||||
|
||||
## help: Show this help message
|
||||
help:
|
||||
@echo "picoclaw Makefile"
|
||||
|
|
@ -219,6 +330,8 @@ help:
|
|||
@echo " make install # Install to ~/.local/bin"
|
||||
@echo " make uninstall # Remove from /usr/local/bin"
|
||||
@echo " make install-skills # Install skills to workspace"
|
||||
@echo " make docker-build # Build minimal Docker image"
|
||||
@echo " make docker-test # Test MCP tools in Docker"
|
||||
@echo ""
|
||||
@echo "Environment Variables:"
|
||||
@echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)"
|
||||
|
|
|
|||
1465
README.fr.md
1465
README.fr.md
File diff suppressed because it is too large
Load diff
579
README.id.md
Normal file
579
README.id.md
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
<div align="center">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Asisten AI Super Ringan berbasis Go</h1>
|
||||
|
||||
<h3>Perangkat Keras $10 · RAM 10MB · Boot ms · Let's Go, PicoClaw!</h3>
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||
<br>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [English](README.md) | **Bahasa Indonesia**
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> **PicoClaw** adalah proyek open-source independen yang diinisiasi oleh [Sipeed](https://sipeed.com), ditulis sepenuhnya dalam **Go** — bukan fork dari OpenClaw, NanoBot, atau proyek lainnya.
|
||||
|
||||
**PicoClaw** adalah asisten AI pribadi yang super ringan, terinspirasi dari [NanoBot](https://github.com/HKUDS/nanobot). Dibangun ulang dari awal dalam **Go** melalui proses "self-bootstrapping" — AI Agent itu sendiri yang memandu migrasi arsitektur dan optimasi kode.
|
||||
|
||||
**Berjalan di perangkat keras $10 dengan RAM <10MB** — hemat 99% memori dibanding OpenClaw dan 98% lebih murah dari Mac mini!
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||
</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/licheervnano.png" width="400" height="240">
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> [!CAUTION]
|
||||
> **Peringatan Keamanan**
|
||||
>
|
||||
> * **TANPA KRIPTO:** PicoClaw **tidak** menerbitkan token atau cryptocurrency resmi apa pun. Semua klaim di `pump.fun` atau platform trading lainnya adalah **penipuan**.
|
||||
> * **DOMAIN RESMI:** Satu-satunya website resmi adalah **[picoclaw.io](https://picoclaw.io)**, dan website perusahaan adalah **[sipeed.com](https://sipeed.com)**
|
||||
> * **WASPADA:** Banyak domain `.ai/.org/.com/.net/...` telah didaftarkan oleh pihak ketiga. Jangan percaya mereka.
|
||||
> * **CATATAN:** PicoClaw masih dalam tahap pengembangan awal yang cepat. Mungkin ada masalah keamanan yang belum terselesaikan. Jangan deploy ke produksi sebelum v1.0.
|
||||
> * **CATATAN:** PicoClaw baru-baru ini menggabungkan banyak PR. Build terbaru mungkin menggunakan RAM 10-20MB. Optimasi sumber daya direncanakan setelah fitur stabil.
|
||||
|
||||
## 📢 Berita
|
||||
|
||||
2026-03-17 🚀 **v0.2.3 Dirilis!** UI system tray (Windows & Linux), pelacakan status sub-agent (`spawn_status`), eksperimental Gateway hot-reload, gerbang keamanan Cron, dan 2 perbaikan keamanan. PicoClaw telah mencapai **25K Stars**!
|
||||
|
||||
2026-03-09 🎉 **v0.2.1 — Update terbesar sejauh ini!** Dukungan protokol MCP, 4 channel baru (Matrix/IRC/WeCom/Discord Proxy), 3 provider baru (Kimi/Minimax/Avian), pipeline vision, penyimpanan memori JSONL, routing model.
|
||||
|
||||
2026-02-28 📦 **v0.2.0** dirilis dengan dukungan Docker Compose dan Web UI Launcher.
|
||||
|
||||
2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas kini aktif.
|
||||
|
||||
<details>
|
||||
<summary>Berita sebelumnya...</summary>
|
||||
|
||||
2026-02-16 🎉 PicoClaw menembus 12K Stars dalam satu minggu! Peran maintainer komunitas dan [Roadmap](ROADMAP.md) resmi diluncurkan.
|
||||
|
||||
2026-02-13 🎉 PicoClaw menembus 5000 Stars dalam 4 hari! Roadmap proyek dan grup pengembang sedang dalam proses.
|
||||
|
||||
2026-02-09 🎉 **PicoClaw Diluncurkan!** Dibangun dalam 1 hari untuk menghadirkan AI Agent ke perangkat keras $10 dengan RAM <10MB. Let's Go, PicoClaw!
|
||||
|
||||
</details>
|
||||
|
||||
## ✨ Fitur
|
||||
|
||||
🪶 **Super Ringan**: Penggunaan memori inti <10MB — 99% lebih kecil dari OpenClaw.*
|
||||
|
||||
💰 **Biaya Minimal**: Cukup efisien untuk berjalan di perangkat keras $10 — 98% lebih murah dari Mac mini.
|
||||
|
||||
⚡️ **Boot Secepat Kilat**: Startup 400x lebih cepat. Boot dalam <1 detik bahkan di prosesor single-core 0,6GHz.
|
||||
|
||||
🌍 **Portabilitas Sejati**: Satu binary untuk RISC-V, ARM, MIPS, dan x86. Satu binary, jalan di mana saja!
|
||||
|
||||
🤖 **AI-Bootstrapped**: Implementasi Go native murni — 95% kode inti dihasilkan oleh Agent dengan penyempurnaan human-in-the-loop.
|
||||
|
||||
🔌 **Dukungan MCP**: Integrasi [Model Context Protocol](https://modelcontextprotocol.io/) native — hubungkan server MCP mana pun untuk memperluas kapabilitas Agent.
|
||||
|
||||
👁️ **Pipeline Vision**: Kirim gambar dan file langsung ke Agent — encoding base64 otomatis untuk LLM multimodal.
|
||||
|
||||
🧠 **Routing Cerdas**: Routing model berbasis aturan — kueri sederhana diarahkan ke model ringan, menghemat biaya API.
|
||||
|
||||
_*Build terbaru mungkin menggunakan 10-20MB karena penggabungan PR yang cepat. Optimasi sumber daya direncanakan. Perbandingan kecepatan boot berdasarkan benchmark single-core 0,8GHz (lihat tabel di bawah)._
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||
| **Bahasa** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||
| **Waktu Boot**</br>(core 0,8GHz) | >500d | >30d | **<1d** |
|
||||
| **Biaya** | Mac Mini $599 | Kebanyakan board Linux ~$50 | **Board Linux mana pun**</br>**mulai $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
</div>
|
||||
|
||||
> **[Daftar Kompatibilitas Hardware](docs/hardware-compatibility.md)** — Lihat semua board yang telah diuji, dari RISC-V $5 hingga Raspberry Pi hingga ponsel Android. Board Anda belum terdaftar? Kirim PR!
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||
</p>
|
||||
|
||||
## 🦾 Demonstrasi
|
||||
|
||||
### 🛠️ Alur Kerja Asisten Standar
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">Mode Full-Stack Engineer</p></th>
|
||||
<th><p align="center">Pencatatan & Perencanaan</p></th>
|
||||
<th><p align="center">Pencarian Web & Pembelajaran</p></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Develop · Deploy · Scale</td>
|
||||
<td align="center">Jadwal · Otomasi · Ingat</td>
|
||||
<td align="center">Temukan · Wawasan · Tren</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🐜 Deploy Inovatif dengan Footprint Rendah
|
||||
|
||||
PicoClaw dapat di-deploy di hampir semua perangkat Linux!
|
||||
|
||||
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versi E(Ethernet) atau W(WiFi6), untuk home assistant minimal
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), atau $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), untuk operasi server otomatis
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) atau $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), untuk pengawasan cerdas
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
🌟 Lebih Banyak Kasus Deploy Menanti!
|
||||
|
||||
## 📦 Instalasi
|
||||
|
||||
### Unduh dari picoclaw.io (Direkomendasikan)
|
||||
|
||||
Kunjungi **[picoclaw.io](https://picoclaw.io)** — website resmi mendeteksi platform Anda secara otomatis dan menyediakan unduhan satu klik. Tidak perlu memilih arsitektur secara manual.
|
||||
|
||||
### Unduh binary yang sudah dikompilasi
|
||||
|
||||
Atau, unduh binary untuk platform Anda dari halaman [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
|
||||
|
||||
### Build dari source (untuk pengembangan)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Build binary inti
|
||||
make build
|
||||
|
||||
# Build Web UI Launcher (diperlukan untuk mode WebUI)
|
||||
make build-launcher
|
||||
|
||||
# Build untuk berbagai platform
|
||||
make build-all
|
||||
|
||||
# Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
make build-pi-zero
|
||||
|
||||
# Build dan instal
|
||||
make install
|
||||
```
|
||||
|
||||
**Raspberry Pi Zero 2 W:** Gunakan binary yang sesuai dengan OS Anda: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk build keduanya.
|
||||
|
||||
## 🚀 Panduan Memulai Cepat
|
||||
|
||||
### 🌐 WebUI Launcher (Direkomendasikan untuk Desktop)
|
||||
|
||||
WebUI Launcher menyediakan antarmuka berbasis browser untuk konfigurasi dan chat. Ini adalah cara termudah untuk memulai — tidak perlu pengetahuan command-line.
|
||||
|
||||
**Opsi 1: Klik dua kali (Desktop)**
|
||||
|
||||
Setelah mengunduh dari [picoclaw.io](https://picoclaw.io), klik dua kali `picoclaw-launcher` (atau `picoclaw-launcher.exe` di Windows). Browser Anda akan terbuka otomatis di `http://localhost:18800`.
|
||||
|
||||
**Opsi 2: Command line**
|
||||
|
||||
```bash
|
||||
picoclaw-launcher
|
||||
# Buka http://localhost:18800 di browser Anda
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Akses jarak jauh / Docker / VM:** Tambahkan flag `-public` untuk mendengarkan di semua antarmuka:
|
||||
> ```bash
|
||||
> picoclaw-launcher -public
|
||||
> ```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Memulai:**
|
||||
|
||||
Buka WebUI, lalu: **1)** Konfigurasi Provider (tambahkan API key LLM Anda) -> **2)** Konfigurasi Channel (mis. Telegram) -> **3)** Mulai Gateway -> **4)** Chat!
|
||||
|
||||
Untuk dokumentasi WebUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<details>
|
||||
<summary><b>Docker (alternatif)</b></summary>
|
||||
|
||||
```bash
|
||||
# 1. Clone repo ini
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. Jalankan pertama kali — otomatis membuat docker/data/config.json lalu keluar
|
||||
# (hanya terpicu ketika config.json dan workspace/ keduanya tidak ada)
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# Container mencetak "First-run setup complete." dan berhenti.
|
||||
|
||||
# 3. Atur API key Anda
|
||||
vim docker/data/config.json
|
||||
|
||||
# 4. Mulai
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
# Buka http://localhost:18800
|
||||
```
|
||||
|
||||
> **Pengguna Docker / VM:** Gateway mendengarkan di `127.0.0.1` secara default. Atur `PICOCLAW_GATEWAY_HOST=0.0.0.0` atau gunakan flag `-public` agar dapat diakses dari host.
|
||||
|
||||
```bash
|
||||
# Cek log
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# Hentikan
|
||||
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||
|
||||
# Update
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Direkomendasikan untuk Headless / SSH)
|
||||
|
||||
TUI (Terminal UI) Launcher menyediakan antarmuka terminal lengkap untuk konfigurasi dan manajemen. Ideal untuk server, Raspberry Pi, dan lingkungan headless lainnya.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Memulai:**
|
||||
|
||||
Gunakan menu TUI untuk: **1)** Konfigurasi Provider -> **2)** Konfigurasi Channel -> **3)** Mulai Gateway -> **4)** Chat!
|
||||
|
||||
Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
### 📱 Android
|
||||
|
||||
Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw.
|
||||
|
||||
**Opsi 1: Termux (tersedia sekarang)**
|
||||
|
||||
1. Instal [Termux](https://github.com/termux/termux-app) (unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play)
|
||||
2. Jalankan perintah berikut:
|
||||
|
||||
```bash
|
||||
# Unduh rilis terbaru
|
||||
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
|
||||
tar xzf picoclaw_Linux_arm64.tar.gz
|
||||
pkg install proot
|
||||
termux-chroot ./picoclaw onboard # chroot menyediakan tata letak filesystem Linux standar
|
||||
```
|
||||
|
||||
Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi.
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Opsi 2: Instal APK (segera hadir)**
|
||||
|
||||
APK Android mandiri dengan WebUI bawaan sedang dalam pengembangan. Pantau terus!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (untuk lingkungan dengan sumber daya terbatas)</b></summary>
|
||||
|
||||
Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON.
|
||||
|
||||
**1. Inisialisasi**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
Ini membuat `~/.picoclaw/config.json` dan direktori workspace.
|
||||
|
||||
**2. Konfigurasi** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> Lihat `config/config.example.json` di repo untuk template konfigurasi lengkap dengan semua opsi yang tersedia.
|
||||
|
||||
**3. Chat**
|
||||
|
||||
```bash
|
||||
# Pertanyaan satu kali
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
|
||||
# Mode interaktif
|
||||
picoclaw agent
|
||||
|
||||
# Mulai gateway untuk integrasi aplikasi chat
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🔌 Providers (LLM)
|
||||
|
||||
PicoClaw mendukung 30+ provider LLM melalui konfigurasi `model_list`. Gunakan format `protocol/model`:
|
||||
|
||||
| Provider | Protocol | API Key | Catatan |
|
||||
|----------|----------|---------|---------|
|
||||
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Diperlukan | GPT-5.4, GPT-4o, o3, dll. |
|
||||
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Diperlukan | Claude Opus 4.6, Sonnet 4.6, dll. |
|
||||
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Diperlukan | Gemini 3 Flash, 2.5 Pro, dll. |
|
||||
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Diperlukan | 200+ model, API terpadu |
|
||||
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Diperlukan | GLM-4.7, GLM-5, dll. |
|
||||
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Diperlukan | DeepSeek-V3, DeepSeek-R1 |
|
||||
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Diperlukan | Doubao, model Ark |
|
||||
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Diperlukan | Qwen3, Qwen-Max, dll. |
|
||||
| [Groq](https://console.groq.com/keys) | `groq/` | Diperlukan | Inferensi cepat (Llama, Mixtral) |
|
||||
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Diperlukan | Model Kimi |
|
||||
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Diperlukan | Model MiniMax |
|
||||
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Diperlukan | Mistral Large, Codestral |
|
||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Diperlukan | Model yang di-host NVIDIA |
|
||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Diperlukan | Inferensi cepat |
|
||||
| [Novita AI](https://novita.ai/) | `novita/` | Diperlukan | Berbagai model open |
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | Tidak perlu | Model lokal, self-hosted |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Tidak perlu | Deploy lokal, kompatibel OpenAI |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Bervariasi | Proxy untuk 100+ provider |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Diperlukan | Deploy Azure enterprise |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login dengan device code |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
|
||||
<details>
|
||||
<summary><b>Deploy lokal (Ollama, vLLM, dll.)</b></summary>
|
||||
|
||||
**Ollama:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-llama",
|
||||
"model": "ollama/llama3.1:8b",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**vLLM:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-vllm",
|
||||
"model": "vllm/your-model",
|
||||
"api_base": "http://localhost:8000/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](docs/providers.md).
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 Channels (Aplikasi Chat)
|
||||
|
||||
Bicara dengan PicoClaw Anda melalui 17+ platform pesan:
|
||||
|
||||
| Channel | Pengaturan | Protocol | Dokumentasi |
|
||||
|---------|------------|----------|-------------|
|
||||
| **Telegram** | Mudah (bot token) | Long polling | [Panduan](docs/channels/telegram/README.md) |
|
||||
| **Discord** | Mudah (bot token + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) |
|
||||
| **WhatsApp** | Mudah (scan QR atau bridge URL) | Native / Bridge | [Panduan](docs/chat-apps.md#whatsapp) |
|
||||
| **Weixin** | Mudah (scan QR native) | iLink API | [Panduan](docs/chat-apps.md#weixin) |
|
||||
| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) |
|
||||
| **Slack** | Mudah (bot + app token) | Socket Mode | [Panduan](docs/channels/slack/README.md) |
|
||||
| **Matrix** | Sedang (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) |
|
||||
| **DingTalk** | Sedang (client credentials) | Stream | [Panduan](docs/channels/dingtalk/README.md) |
|
||||
| **Feishu / Lark** | Sedang (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) |
|
||||
| **LINE** | Sedang (credentials + webhook) | Webhook | [Panduan](docs/channels/line/README.md) |
|
||||
| **WeCom Bot** | Sedang (webhook URL) | Webhook | [Panduan](docs/channels/wecom/wecom_bot/README.md) |
|
||||
| **WeCom App** | Sedang (corp credentials) | Webhook | [Panduan](docs/channels/wecom/wecom_app/README.md) |
|
||||
| **WeCom AI Bot** | Sedang (token + AES key) | WebSocket / Webhook | [Panduan](docs/channels/wecom/wecom_aibot/README.md) |
|
||||
| **IRC** | Sedang (server + nick) | IRC protocol | [Panduan](docs/chat-apps.md#irc) |
|
||||
| **OneBot** | Sedang (WebSocket URL) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) |
|
||||
| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) |
|
||||
| **Pico** | Mudah (aktifkan) | Native protocol | Bawaan |
|
||||
| **Pico Client** | Mudah (WebSocket URL) | WebSocket | Bawaan |
|
||||
|
||||
> Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama.
|
||||
|
||||
Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md).
|
||||
|
||||
## 🔧 Tools
|
||||
|
||||
### 🔍 Pencarian Web
|
||||
|
||||
PicoClaw dapat mencari web untuk memberikan informasi terkini. Konfigurasi di `tools.web`:
|
||||
|
||||
| Mesin Pencari | API Key | Tier Gratis | Tautan |
|
||||
|--------------|---------|-------------|--------|
|
||||
| DuckDuckGo | Tidak perlu | Tidak terbatas | Fallback bawaan |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 kueri/hari | Bertenaga AI, dioptimalkan untuk bahasa Mandarin |
|
||||
| [Tavily](https://tavily.com) | Diperlukan | 1000 kueri/bulan | Dioptimalkan untuk AI Agent |
|
||||
| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 kueri/bulan | Cepat dan privat |
|
||||
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Pencarian bertenaga AI |
|
||||
| [SearXNG](https://github.com/searxng/searxng) | Tidak perlu | Self-hosted | Mesin metasearch gratis |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | Diperlukan | Bervariasi | Pencarian web Zhipu |
|
||||
|
||||
### ⚙️ Tools Lainnya
|
||||
|
||||
PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](docs/tools_configuration.md) untuk detail.
|
||||
|
||||
## 🎯 Skills
|
||||
|
||||
Skills adalah kapabilitas modular yang memperluas Agent Anda. Dimuat dari file `SKILL.md` di workspace Anda.
|
||||
|
||||
**Instal skills dari ClawHub:**
|
||||
|
||||
```bash
|
||||
picoclaw skills search "web scraping"
|
||||
picoclaw skills install <skill-name>
|
||||
```
|
||||
|
||||
**Konfigurasi token ClawHub** (opsional, untuk rate limit lebih tinggi):
|
||||
|
||||
Tambahkan ke `config.json` Anda:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"auth_token": "your-clawhub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](docs/tools_configuration.md#skills-tool).
|
||||
|
||||
## 🔗 MCP (Model Context Protocol)
|
||||
|
||||
PicoClaw mendukung [MCP](https://modelcontextprotocol.io/) secara native — hubungkan server MCP mana pun untuk memperluas kapabilitas Agent Anda dengan tools dan sumber data eksternal.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Untuk konfigurasi MCP lengkap (transport stdio, SSE, HTTP, Tool Discovery), lihat [Konfigurasi Tools - MCP](docs/tools_configuration.md#mcp-tool).
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Bergabung dengan Jaringan Sosial Agent
|
||||
|
||||
Hubungkan PicoClaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi mana pun.
|
||||
|
||||
**Baca `https://clawdchat.ai/skill.md` dan ikuti instruksi untuk bergabung dengan [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
## 🖥️ Referensi CLI
|
||||
|
||||
| Perintah | Deskripsi |
|
||||
| -------------------------- | -------------------------------- |
|
||||
| `picoclaw onboard` | Inisialisasi konfigurasi & workspace |
|
||||
| `picoclaw auth weixin` | Hubungkan akun WeChat via QR |
|
||||
| `picoclaw agent -m "..."` | Chat dengan agent |
|
||||
| `picoclaw agent` | Mode chat interaktif |
|
||||
| `picoclaw gateway` | Mulai gateway |
|
||||
| `picoclaw status` | Tampilkan status |
|
||||
| `picoclaw version` | Tampilkan info versi |
|
||||
| `picoclaw model` | Lihat atau ganti model default |
|
||||
| `picoclaw cron list` | Daftar semua tugas terjadwal |
|
||||
| `picoclaw cron add ...` | Tambah tugas terjadwal |
|
||||
| `picoclaw cron disable` | Nonaktifkan tugas terjadwal |
|
||||
| `picoclaw cron remove` | Hapus tugas terjadwal |
|
||||
| `picoclaw skills list` | Daftar skill yang terinstal |
|
||||
| `picoclaw skills install` | Instal skill |
|
||||
| `picoclaw migrate` | Migrasi data dari versi lama |
|
||||
| `picoclaw auth login` | Autentikasi dengan provider |
|
||||
|
||||
### ⏰ Tugas Terjadwal / Pengingat
|
||||
|
||||
PicoClaw mendukung pengingat terjadwal dan tugas berulang melalui tool `cron`:
|
||||
|
||||
* **Pengingat satu kali**: "Ingatkan saya dalam 10 menit" -> terpicu sekali setelah 10 menit
|
||||
* **Tugas berulang**: "Ingatkan saya setiap 2 jam" -> terpicu setiap 2 jam
|
||||
* **Ekspresi cron**: "Ingatkan saya jam 9 pagi setiap hari" -> menggunakan ekspresi cron
|
||||
|
||||
## 📚 Dokumentasi
|
||||
|
||||
Untuk panduan lengkap di luar README ini:
|
||||
|
||||
| Topik | Deskripsi |
|
||||
|-------|-----------|
|
||||
| [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent |
|
||||
| [Aplikasi Chat](docs/chat-apps.md) | Semua 17+ panduan pengaturan channel |
|
||||
| [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sandbox keamanan |
|
||||
| [Providers & Models](docs/providers.md) | 30+ provider LLM, routing model, konfigurasi model_list |
|
||||
| [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async |
|
||||
| [Hooks](docs/hooks/README.md) | Sistem hook berbasis event: observer, interceptor, approval hook |
|
||||
| [Steering](docs/steering.md) | Menyuntikkan pesan ke dalam loop agent yang sedang berjalan |
|
||||
| [SubTurn](docs/subturn.md) | Koordinasi subagent, kontrol konkurensi, siklus hidup |
|
||||
| [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya |
|
||||
| [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan per-tool, kebijakan exec, MCP, Skills |
|
||||
| [Kompatibilitas Hardware](docs/hardware-compatibility.md) | Board yang telah diuji, persyaratan minimum |
|
||||
|
||||
## 🤝 Kontribusi & Roadmap
|
||||
|
||||
PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca.
|
||||
|
||||
Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](CONTRIBUTING.md) untuk panduan.
|
||||
|
||||
Grup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge!
|
||||
|
||||
Grup Pengguna:
|
||||
|
||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
WeChat:
|
||||
<img src="assets/wechat.png" alt="Kode QR grup WeChat" width="512">
|
||||
|
||||
578
README.it.md
Normal file
578
README.it.md
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
<div align="center">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Assistente IA Ultra-Efficiente in Go</h1>
|
||||
|
||||
<h3>Hardware da $10 · 10MB di RAM · Avvio in ms · Let's Go, PicoClaw!</h3>
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||
<br>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> **PicoClaw** è un progetto open-source indipendente avviato da [Sipeed](https://sipeed.com), scritto interamente in **Go** da zero — non è un fork di OpenClaw, NanoBot o di qualsiasi altro progetto.
|
||||
|
||||
**PicoClaw** è un assistente IA personale ultra-leggero ispirato a [NanoBot](https://github.com/HKUDS/nanobot). È stato riscritto da zero in **Go** attraverso un processo di "auto-bootstrapping" — l'Agent IA stesso ha guidato la migrazione architetturale e l'ottimizzazione del codice.
|
||||
|
||||
**Funziona su hardware da $10 con <10MB di RAM** — il 99% di memoria in meno rispetto a OpenClaw e il 98% più economico di un Mac mini!
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||
</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/licheervnano.png" width="400" height="240">
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> [!CAUTION]
|
||||
> **Avviso di Sicurezza**
|
||||
>
|
||||
> * **NESSUNA CRYPTO:** PicoClaw **non** ha emesso token o criptovalute ufficiali. Qualsiasi annuncio su `pump.fun` o altre piattaforme di trading è una **truffa**.
|
||||
> * **DOMINIO UFFICIALE:** L'**UNICO** sito ufficiale è **[picoclaw.io](https://picoclaw.io)**, e il sito aziendale è **[sipeed.com](https://sipeed.com)**
|
||||
> * **ATTENZIONE:** Molti domini `.ai/.org/.com/.net/...` sono stati registrati da terze parti. Non fidarti di essi.
|
||||
> * **NOTA:** PicoClaw è in fase di sviluppo iniziale rapido. Potrebbero esserci problemi di sicurezza non risolti. Non distribuire in produzione prima della v1.0.
|
||||
> * **NOTA:** PicoClaw ha recentemente unito molte PR. Le build recenti potrebbero usare 10-20MB di RAM. L'ottimizzazione delle risorse è pianificata dopo la stabilizzazione delle funzionalità.
|
||||
|
||||
## 📢 Novità
|
||||
|
||||
2026-03-17 🚀 **v0.2.3 rilasciata!** Interfaccia system tray (Windows & Linux), query sullo stato dei sub-agent (`spawn_status`), hot-reload sperimentale del Gateway, gate di sicurezza per Cron e 2 correzioni di sicurezza. PicoClaw raggiunge **25K Stars**!
|
||||
|
||||
2026-03-09 🎉 **v0.2.1 — Il più grande aggiornamento di sempre!** Supporto al protocollo MCP, 4 nuovi canali (Matrix/IRC/WeCom/Discord Proxy), 3 nuovi provider (Kimi/Minimax/Avian), pipeline di visione, store di memoria JSONL e routing dei modelli.
|
||||
|
||||
2026-02-28 📦 **v0.2.0** rilasciata con supporto Docker Compose e Web UI Launcher.
|
||||
|
||||
2026-02-26 🎉 PicoClaw raggiunge **20K stelle** in soli 17 giorni! Orchestrazione automatica dei canali e interfacce di capacità sono attive.
|
||||
|
||||
<details>
|
||||
<summary>Notizie precedenti...</summary>
|
||||
|
||||
2026-02-16 🎉 PicoClaw supera 12K stelle in una settimana! Ruoli di maintainer della community e [Roadmap](ROADMAP.md) pubblicati ufficialmente.
|
||||
|
||||
2026-02-13 🎉 PicoClaw supera 5000 stelle in 4 giorni! Roadmap del progetto e gruppi sviluppatori in fase di avvio.
|
||||
|
||||
2026-02-09 🎉 **PicoClaw lanciato!** Costruito in 1 giorno per portare gli AI Agent su hardware da $10 con <10MB di RAM. Let's Go, PicoClaw!
|
||||
|
||||
</details>
|
||||
|
||||
## ✨ Caratteristiche
|
||||
|
||||
🪶 **Ultra-Leggero**: Impronta di memoria <10MB — il 99% più piccolo rispetto a OpenClaw.*
|
||||
|
||||
💰 **Costo Minimo**: Abbastanza efficiente da girare su hardware da $10 — il 98% più economico di un Mac mini.
|
||||
|
||||
⚡️ **Avvio Fulmineo**: Avvio 400 volte più veloce. Boot in meno di 1 secondo anche su un singolo core a 0,6 GHz.
|
||||
|
||||
🌍 **Vera Portabilità**: Singolo binario per RISC-V, ARM, MIPS e x86. Un binario, funziona ovunque!
|
||||
|
||||
🤖 **Auto-Costruito dall'IA**: Implementazione nativa in Go — il 95% del codice core è stato generato da un Agent e perfezionato tramite revisione umana nel ciclo.
|
||||
|
||||
🔌 **Supporto MCP**: Integrazione nativa del [Model Context Protocol](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità dell'Agent.
|
||||
|
||||
👁️ **Pipeline di Visione**: Invia immagini e file direttamente all'Agent — codifica base64 automatica per LLM multimodali.
|
||||
|
||||
🧠 **Routing Intelligente**: Routing dei modelli basato su regole — le query semplici vanno verso modelli leggeri, risparmiando sui costi API.
|
||||
|
||||
_*Le build recenti potrebbero usare 10-20MB a causa delle fusioni rapide di PR. L'ottimizzazione delle risorse è pianificata. Il confronto dell'avvio è basato su benchmark con singolo core a 0,8 GHz (vedi tabella sotto)._
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||
| **Linguaggio** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||
| **Avvio**</br>(core 0,8 GHz) | >500s | >30s | **<1s** |
|
||||
| **Costo** | Mac Mini $599 | La maggior parte degli SBC Linux ~$50 | **Qualsiasi scheda Linux**</br>**a partire da $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
</div>
|
||||
|
||||
> **[Lista di Compatibilità Hardware](docs/hardware-compatibility.md)** — Vedi tutte le schede testate, dai $5 RISC-V al Raspberry Pi ai telefoni Android. La tua scheda non è elencata? Invia una PR!
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||
</p>
|
||||
|
||||
## 🦾 Dimostrazione
|
||||
|
||||
### 🛠️ Flussi di Lavoro Standard dell'Assistente
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">Modalità Ingegnere Full-Stack</p></th>
|
||||
<th><p align="center">Log & Pianificazione</p></th>
|
||||
<th><p align="center">Ricerca Web & Apprendimento</p></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Sviluppa · Distribuisci · Scala</td>
|
||||
<td align="center">Pianifica · Automatizza · Memorizza</td>
|
||||
<td align="center">Scopri · Analizza · Tendenze</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🐜 Deploy Innovativo a Bassa Impronta
|
||||
|
||||
PicoClaw può essere distribuito su quasi qualsiasi dispositivo Linux!
|
||||
|
||||
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versione E (Ethernet) o W (WiFi6), per un assistente domotico minimale
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), o $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), per la manutenzione automatizzata dei server
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) o $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), per la sorveglianza intelligente
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
🌟 Molti altri scenari di deploy ti aspettano!
|
||||
|
||||
## 📦 Installazione
|
||||
|
||||
### Scarica da picoclaw.io (Consigliato)
|
||||
|
||||
Visita **[picoclaw.io](https://picoclaw.io)** — il sito ufficiale rileva automaticamente la tua piattaforma e fornisce il download con un clic. Non è necessario scegliere manualmente l'architettura.
|
||||
|
||||
### Scarica il binario precompilato
|
||||
|
||||
In alternativa, scarica il binario per la tua piattaforma dalla pagina delle [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
|
||||
|
||||
### Compila dai sorgenti (per lo sviluppo)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Compila il binario core
|
||||
make build
|
||||
|
||||
# Compila il Web UI Launcher (necessario per la modalità WebUI)
|
||||
make build-launcher
|
||||
|
||||
# Compila per più piattaforme
|
||||
make build-all
|
||||
|
||||
# Compila per Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
make build-pi-zero
|
||||
|
||||
# Compila e installa
|
||||
make install
|
||||
```
|
||||
|
||||
**Raspberry Pi Zero 2 W:** Usa il binario che corrisponde al tuo OS: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Oppure esegui `make build-pi-zero` per compilare entrambi.
|
||||
|
||||
## 🚀 Guida Rapida
|
||||
|
||||
### 🌐 WebUI Launcher (Consigliato per Desktop)
|
||||
|
||||
Il WebUI Launcher fornisce un'interfaccia basata su browser per la configurazione e la chat. È il modo più semplice per iniziare — non è richiesta alcuna conoscenza della riga di comando.
|
||||
|
||||
**Opzione 1: Doppio clic (Desktop)**
|
||||
|
||||
Dopo aver scaricato da [picoclaw.io](https://picoclaw.io), fai doppio clic su `picoclaw-launcher` (o `picoclaw-launcher.exe` su Windows). Il browser si aprirà automaticamente su `http://localhost:18800`.
|
||||
|
||||
**Opzione 2: Riga di comando**
|
||||
|
||||
```bash
|
||||
picoclaw-launcher
|
||||
# Apri http://localhost:18800 nel browser
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Accesso remoto / Docker / VM:** Aggiungi il flag `-public` per ascoltare su tutte le interfacce:
|
||||
> ```bash
|
||||
> picoclaw-launcher -public
|
||||
> ```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Per iniziare:**
|
||||
|
||||
Apri il WebUI, poi: **1)** Configura un Provider (aggiungi la tua API key LLM) -> **2)** Configura un Channel (es. Telegram) -> **3)** Avvia il Gateway -> **4)** Chatta!
|
||||
|
||||
Per la documentazione dettagliata del WebUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<details>
|
||||
<summary><b>Docker (alternativa)</b></summary>
|
||||
|
||||
```bash
|
||||
# 1. Clona questo repo
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. Prima esecuzione — genera automaticamente docker/data/config.json poi si ferma
|
||||
# (si attiva solo quando sia config.json che workspace/ sono assenti)
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# Il container stampa "First-run setup complete." e si ferma.
|
||||
|
||||
# 3. Imposta le tue API key
|
||||
vim docker/data/config.json
|
||||
|
||||
# 4. Avvia
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
# Apri http://localhost:18800
|
||||
```
|
||||
|
||||
> **Utenti Docker / VM:** Il Gateway ascolta su `127.0.0.1` per impostazione predefinita. Imposta `PICOCLAW_GATEWAY_HOST=0.0.0.0` o usa il flag `-public` per renderlo accessibile dall'host.
|
||||
|
||||
```bash
|
||||
# Controlla i log
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# Ferma
|
||||
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||
|
||||
# Aggiorna
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Consigliato per Headless / SSH)
|
||||
|
||||
Il TUI (Terminal UI) Launcher fornisce un'interfaccia terminale completa per la configurazione e la gestione. Ideale per server, Raspberry Pi e altri ambienti headless.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Per iniziare:**
|
||||
|
||||
Usa i menu TUI per: **1)** Configurare un Provider -> **2)** Configurare un Channel -> **3)** Avviare il Gateway -> **4)** Chattare!
|
||||
|
||||
Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
### 📱 Android
|
||||
|
||||
Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw.
|
||||
|
||||
**Opzione 1: Termux (disponibile ora)**
|
||||
|
||||
1. Installa [Termux](https://github.com/termux/termux-app) (scarica da [GitHub Releases](https://github.com/termux/termux-app/releases), o cerca su F-Droid / Google Play)
|
||||
2. Esegui i seguenti comandi:
|
||||
|
||||
```bash
|
||||
# Scarica l'ultima release
|
||||
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
|
||||
tar xzf picoclaw_Linux_arm64.tar.gz
|
||||
pkg install proot
|
||||
termux-chroot ./picoclaw onboard # chroot fornisce un layout standard del filesystem Linux
|
||||
```
|
||||
|
||||
Poi segui la sezione Terminal Launcher qui sotto per completare la configurazione.
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Opzione 2: APK Install (prossimamente)**
|
||||
|
||||
Un APK Android standalone con WebUI integrato è in sviluppo. Resta sintonizzato!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (per ambienti con risorse limitate)</b></summary>
|
||||
|
||||
Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON.
|
||||
|
||||
**1. Inizializza**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
Questo crea `~/.picoclaw/config.json` e la directory workspace.
|
||||
|
||||
**2. Configura** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> Vedi `config/config.example.json` nel repo per un template di configurazione completo con tutte le opzioni disponibili.
|
||||
|
||||
**3. Chatta**
|
||||
|
||||
```bash
|
||||
# Domanda singola
|
||||
picoclaw agent -m "Quanto fa 2+2?"
|
||||
|
||||
# Modalità interattiva
|
||||
picoclaw agent
|
||||
|
||||
# Avvia il gateway per l'integrazione con app di chat
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🔌 Provider (LLM)
|
||||
|
||||
PicoClaw supporta 30+ provider LLM tramite la configurazione `model_list`. Usa il formato `protocollo/modello`:
|
||||
|
||||
| Provider | Protocollo | API Key | Note |
|
||||
|----------|------------|---------|------|
|
||||
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Richiesta | GPT-5.4, GPT-4o, o3, ecc. |
|
||||
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Richiesta | Claude Opus 4.6, Sonnet 4.6, ecc. |
|
||||
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Richiesta | Gemini 3 Flash, 2.5 Pro, ecc. |
|
||||
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Richiesta | 200+ modelli, API unificata |
|
||||
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Richiesta | GLM-4.7, GLM-5, ecc. |
|
||||
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Richiesta | DeepSeek-V3, DeepSeek-R1 |
|
||||
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Richiesta | Doubao, modelli Ark |
|
||||
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Richiesta | Qwen3, Qwen-Max, ecc. |
|
||||
| [Groq](https://console.groq.com/keys) | `groq/` | Richiesta | Inferenza veloce (Llama, Mixtral) |
|
||||
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Richiesta | Modelli Kimi |
|
||||
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Richiesta | Modelli MiniMax |
|
||||
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Richiesta | Mistral Large, Codestral |
|
||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Richiesta | Modelli ospitati NVIDIA |
|
||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Richiesta | Inferenza veloce |
|
||||
| [Novita AI](https://novita.ai/) | `novita/` | Richiesta | Vari modelli open |
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | Non necessaria | Modelli locali, self-hosted |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Non necessaria | Deploy locale, compatibile OpenAI |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Variabile | Proxy per 100+ provider |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Richiesta | Deploy Azure enterprise |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login con device code |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
|
||||
<details>
|
||||
<summary><b>Deploy locale (Ollama, vLLM, ecc.)</b></summary>
|
||||
|
||||
**Ollama:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-llama",
|
||||
"model": "ollama/llama3.1:8b",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**vLLM:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-vllm",
|
||||
"model": "vllm/your-model",
|
||||
"api_base": "http://localhost:8000/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](docs/providers.md).
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 Channel (App di Chat)
|
||||
|
||||
Parla con il tuo PicoClaw attraverso 17+ piattaforme di messaggistica:
|
||||
|
||||
| Channel | Configurazione | Protocollo | Docs |
|
||||
|---------|----------------|------------|------|
|
||||
| **Telegram** | Facile (bot token) | Long polling | [Guida](docs/channels/telegram/README.md) |
|
||||
| **Discord** | Facile (bot token + intents) | WebSocket | [Guida](docs/channels/discord/README.md) |
|
||||
| **WhatsApp** | Facile (QR scan o bridge URL) | Nativo / Bridge | [Guida](docs/chat-apps.md#whatsapp) |
|
||||
| **Weixin** | Facile (scan QR nativo) | iLink API | [Guida](docs/chat-apps.md#weixin) |
|
||||
| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guida](docs/channels/qq/README.md) |
|
||||
| **Slack** | Facile (bot + app token) | Socket Mode | [Guida](docs/channels/slack/README.md) |
|
||||
| **Matrix** | Medio (homeserver + token) | Sync API | [Guida](docs/channels/matrix/README.md) |
|
||||
| **DingTalk** | Medio (credenziali client) | Stream | [Guida](docs/channels/dingtalk/README.md) |
|
||||
| **Feishu / Lark** | Medio (App ID + Secret) | WebSocket/SDK | [Guida](docs/channels/feishu/README.md) |
|
||||
| **LINE** | Medio (credenziali + webhook) | Webhook | [Guida](docs/channels/line/README.md) |
|
||||
| **WeCom Bot** | Medio (webhook URL) | Webhook | [Guida](docs/channels/wecom/wecom_bot/README.md) |
|
||||
| **WeCom App** | Medio (credenziali aziendali) | Webhook | [Guida](docs/channels/wecom/wecom_app/README.md) |
|
||||
| **WeCom AI Bot** | Medio (token + AES key) | WebSocket / Webhook | [Guida](docs/channels/wecom/wecom_aibot/README.md) |
|
||||
| **IRC** | Medio (server + nick) | Protocollo IRC | [Guida](docs/chat-apps.md#irc) |
|
||||
| **OneBot** | Medio (WebSocket URL) | OneBot v11 | [Guida](docs/channels/onebot/README.md) |
|
||||
| **MaixCam** | Facile (abilita) | TCP socket | [Guida](docs/channels/maixcam/README.md) |
|
||||
| **Pico** | Facile (abilita) | Protocollo nativo | Integrato |
|
||||
| **Pico Client** | Facile (WebSocket URL) | WebSocket | Integrato |
|
||||
|
||||
> Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso.
|
||||
|
||||
Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md).
|
||||
|
||||
## 🔧 Strumenti
|
||||
|
||||
### 🔍 Ricerca Web
|
||||
|
||||
PicoClaw può cercare sul web per fornire informazioni aggiornate. Configura in `tools.web`:
|
||||
|
||||
| Motore di Ricerca | API Key | Piano Gratuito | Link |
|
||||
|-------------------|---------|----------------|------|
|
||||
| DuckDuckGo | Non necessaria | Illimitato | Fallback integrato |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Richiesta | 1000 query/giorno | IA, ottimizzato per il cinese |
|
||||
| [Tavily](https://tavily.com) | Richiesta | 1000 query/mese | Ottimizzato per AI Agent |
|
||||
| [Brave Search](https://brave.com/search/api) | Richiesta | 2000 query/mese | Veloce e privato |
|
||||
| [Perplexity](https://www.perplexity.ai) | Richiesta | A pagamento | Ricerca potenziata dall'IA |
|
||||
| [SearXNG](https://github.com/searxng/searxng) | Non necessaria | Self-hosted | Metasearch engine gratuito |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | Richiesta | Variabile | Ricerca web Zhipu |
|
||||
|
||||
### ⚙️ Altri Strumenti
|
||||
|
||||
PicoClaw include strumenti integrati per operazioni su file, esecuzione di codice, pianificazione e altro. Vedi [Configurazione degli Strumenti](docs/tools_configuration.md) per i dettagli.
|
||||
|
||||
## 🎯 Skill
|
||||
|
||||
Le Skill sono capacità modulari che estendono il tuo Agent. Vengono caricate dai file `SKILL.md` nel tuo workspace.
|
||||
|
||||
**Installa skill da ClawHub:**
|
||||
|
||||
```bash
|
||||
picoclaw skills search "web scraping"
|
||||
picoclaw skills install <skill-name>
|
||||
```
|
||||
|
||||
**Configura il token ClawHub** (opzionale, per limiti di frequenza più alti):
|
||||
|
||||
Aggiungi al tuo `config.json`:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"auth_token": "your-clawhub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](docs/tools_configuration.md#skills-tool).
|
||||
|
||||
## 🔗 MCP (Model Context Protocol)
|
||||
|
||||
PicoClaw supporta nativamente [MCP](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità del tuo Agent con strumenti e sorgenti di dati esterni.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](docs/tools_configuration.md#mcp-tool).
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Unisciti al Social Network degli Agent
|
||||
|
||||
Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singolo messaggio tramite CLI o qualsiasi app di chat integrata.
|
||||
|
||||
**Leggi `https://clawdchat.ai/skill.md` e segui le istruzioni per unirti a [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
## 🖥️ Riferimento CLI
|
||||
|
||||
| Comando | Descrizione |
|
||||
| ------------------------- | ---------------------------------- |
|
||||
| `picoclaw onboard` | Inizializza config & workspace |
|
||||
| `picoclaw auth weixin` | Connetti account WeChat tramite QR |
|
||||
| `picoclaw agent -m "..."` | Chatta con l'agent |
|
||||
| `picoclaw agent` | Modalità chat interattiva |
|
||||
| `picoclaw gateway` | Avvia il gateway |
|
||||
| `picoclaw status` | Mostra lo stato |
|
||||
| `picoclaw version` | Mostra le info sulla versione |
|
||||
| `picoclaw model` | Visualizza o cambia il modello predefinito |
|
||||
| `picoclaw cron list` | Elenca tutti i job pianificati |
|
||||
| `picoclaw cron add ...` | Aggiunge un job pianificato |
|
||||
| `picoclaw cron disable` | Disabilita un job pianificato |
|
||||
| `picoclaw cron remove` | Rimuove un job pianificato |
|
||||
| `picoclaw skills list` | Elenca le skill installate |
|
||||
| `picoclaw skills install` | Installa una skill |
|
||||
| `picoclaw migrate` | Migra i dati dalle versioni precedenti |
|
||||
| `picoclaw auth login` | Autenticazione con i provider |
|
||||
|
||||
### ⏰ Task Pianificati / Promemoria
|
||||
|
||||
PicoClaw supporta promemoria pianificati e task ricorrenti tramite lo strumento `cron`:
|
||||
|
||||
* **Promemoria una tantum**: "Ricordami tra 10 minuti" -> si attiva una volta dopo 10 min
|
||||
* **Task ricorrenti**: "Ricordami ogni 2 ore" -> si attiva ogni 2 ore
|
||||
* **Espressioni cron**: "Ricordami alle 9 ogni giorno" -> usa un'espressione cron
|
||||
|
||||
## 📚 Documentazione
|
||||
|
||||
Per guide dettagliate oltre questo README:
|
||||
|
||||
| Argomento | Descrizione |
|
||||
|-----------|-------------|
|
||||
| [Docker & Avvio Rapido](docs/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent |
|
||||
| [App di Chat](docs/chat-apps.md) | Tutte le guide di configurazione per 17+ channel |
|
||||
| [Configurazione](docs/configuration.md) | Variabili d'ambiente, struttura del workspace, sandbox di sicurezza |
|
||||
| [Provider & Modelli](docs/providers.md) | 30+ provider LLM, routing dei modelli, configurazione model_list |
|
||||
| [Spawn & Task Asincroni](docs/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent |
|
||||
| [Hooks](docs/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook |
|
||||
| [Steering](docs/steering.md) | Iniettare messaggi in un loop agent in esecuzione |
|
||||
| [SubTurn](docs/subturn.md) | Coordinamento subagent, controllo concorrenza, ciclo di vita |
|
||||
| [Risoluzione Problemi](docs/troubleshooting.md) | Problemi comuni e soluzioni |
|
||||
| [Configurazione degli Strumenti](docs/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec, MCP, Skill |
|
||||
| [Compatibilità Hardware](docs/hardware-compatibility.md) | Schede testate, requisiti minimi |
|
||||
|
||||
## 🤝 Contribuisci & Roadmap
|
||||
|
||||
Le PR sono benvenute! Il codice è volutamente piccolo e leggibile.
|
||||
|
||||
Consulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](CONTRIBUTING.md) per le linee guida.
|
||||
|
||||
Gruppo sviluppatori in costruzione, unisciti dopo la tua prima PR accettata!
|
||||
|
||||
Gruppi utenti:
|
||||
|
||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
WeChat:
|
||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||
1386
README.ja.md
1386
README.ja.md
File diff suppressed because it is too large
Load diff
1418
README.pt-br.md
1418
README.pt-br.md
File diff suppressed because it is too large
Load diff
1398
README.vi.md
1398
README.vi.md
File diff suppressed because it is too large
Load diff
1018
README.zh.md
1018
README.zh.md
File diff suppressed because it is too large
Load diff
BIN
assets/hardware-banner.jpg
Normal file
BIN
assets/hardware-banner.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
BIN
assets/launcher-tui.jpg
Normal file
BIN
assets/launcher-tui.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
BIN
assets/launcher-webui.jpg
Normal file
BIN
assets/launcher-webui.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
BIN
assets/logo.webp
Normal file
BIN
assets/logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 61 KiB |
236
cmd/picoclaw-launcher-tui/config/config.go
Normal file
236
cmd/picoclaw-launcher-tui/config/config.go
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
// Package config provides types and I/O for ~/.picoclaw/tui.toml.
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||
)
|
||||
|
||||
// DefaultConfigPath returns the default path to the tui.toml config file.
|
||||
func DefaultConfigPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw", "tui.toml")
|
||||
}
|
||||
|
||||
// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml.
|
||||
type TUIConfig struct {
|
||||
Version string `toml:"version"`
|
||||
Model Model `toml:"model"`
|
||||
Provider Provider `toml:"provider"`
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Type string `toml:"type"` // "provider" (default) | "manual"
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
Schemes []Scheme `toml:"schemes"`
|
||||
Users []User `toml:"users"`
|
||||
Current ProviderCurrent `toml:"current"`
|
||||
}
|
||||
|
||||
type Scheme struct {
|
||||
Name string `toml:"name"` // unique key
|
||||
BaseURL string `toml:"baseURL"` // required
|
||||
Type string `toml:"type"` // "openai-compatible" (default) | "anthropic"
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Name string `toml:"name"`
|
||||
Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique
|
||||
Type string `toml:"type"` // "key" (default) | "OAuth"
|
||||
Key string `toml:"key"`
|
||||
}
|
||||
|
||||
type ProviderCurrent struct {
|
||||
Scheme string `toml:"scheme"` // references Scheme.Name
|
||||
User string `toml:"user"` // references User.Name where User.Scheme == Scheme
|
||||
Model string `toml:"model"` // from GET <baseURL>/models
|
||||
}
|
||||
|
||||
// DefaultConfig returns a minimal valid TUIConfig.
|
||||
func DefaultConfig() *TUIConfig {
|
||||
return &TUIConfig{
|
||||
Version: "1.0",
|
||||
Model: Model{Type: "provider"},
|
||||
Provider: Provider{
|
||||
Schemes: []Scheme{},
|
||||
Users: []User{},
|
||||
Current: ProviderCurrent{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the TUI config from path. Returns a default config if the file does not exist.
|
||||
func Load(path string) (*TUIConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
cfg := DefaultConfig()
|
||||
if _, err := toml.Decode(string(data), cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
applyDefaults(cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Save writes cfg to path atomically (safe for flash / SD storage).
|
||||
func Save(path string, cfg *TUIConfig) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := toml.NewEncoder(&buf)
|
||||
if err := enc.Encode(cfg); err != nil {
|
||||
return fmt.Errorf("failed to encode config: %w", err)
|
||||
}
|
||||
if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil {
|
||||
return fmt.Errorf("failed to write config file %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyDefaults(cfg *TUIConfig) {
|
||||
if cfg.Version == "" {
|
||||
cfg.Version = "1.0"
|
||||
}
|
||||
if cfg.Model.Type == "" {
|
||||
cfg.Model.Type = "provider"
|
||||
}
|
||||
for i := range cfg.Provider.Schemes {
|
||||
if cfg.Provider.Schemes[i].Type == "" {
|
||||
cfg.Provider.Schemes[i].Type = "openai-compatible"
|
||||
}
|
||||
}
|
||||
for i := range cfg.Provider.Users {
|
||||
if cfg.Provider.Users[i].Type == "" {
|
||||
cfg.Provider.Users[i].Type = "key"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SchemeByName returns the first Scheme whose Name matches, or nil.
|
||||
func (p *Provider) SchemeByName(name string) *Scheme {
|
||||
for i := range p.Schemes {
|
||||
if p.Schemes[i].Name == name {
|
||||
return &p.Schemes[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UsersForScheme returns all users whose Scheme field matches schemeName.
|
||||
func (p *Provider) UsersForScheme(schemeName string) []User {
|
||||
var out []User
|
||||
for _, u := range p.Users {
|
||||
if u.Scheme == schemeName {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json
|
||||
// Adds/replaces a "tui-prefer" model entry and sets it as the default model.
|
||||
// Preserves all other existing fields in the config file unchanged.
|
||||
func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
mainConfigPath := filepath.Join(home, ".picoclaw", "config.json")
|
||||
|
||||
var cfg map[string]any
|
||||
if data, readErr := os.ReadFile(mainConfigPath); readErr == nil {
|
||||
if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
} else {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
|
||||
if _, ok := cfg["agents"]; !ok {
|
||||
cfg["agents"] = make(map[string]any)
|
||||
}
|
||||
agents, ok := cfg["agents"].(map[string]any)
|
||||
if ok {
|
||||
if _, ok := agents["defaults"]; !ok {
|
||||
agents["defaults"] = make(map[string]any)
|
||||
}
|
||||
defaults, ok := agents["defaults"].(map[string]any)
|
||||
if ok {
|
||||
defaults["model"] = "tui-prefer"
|
||||
}
|
||||
}
|
||||
|
||||
tuiModel := map[string]any{
|
||||
"model_name": "tui-prefer",
|
||||
"model": modelID,
|
||||
"api_key": user.Key,
|
||||
"api_base": scheme.BaseURL,
|
||||
}
|
||||
|
||||
modelList := []any{}
|
||||
if ml, ok := cfg["model_list"].([]any); ok {
|
||||
modelList = ml
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, m := range modelList {
|
||||
if entry, ok := m.(map[string]any); ok {
|
||||
if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" {
|
||||
modelList[i] = tuiModel
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
modelList = append(modelList, tuiModel)
|
||||
}
|
||||
cfg["model_list"] = modelList
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(mainConfigPath, data, 0o600)
|
||||
}
|
||||
|
||||
func (cfg *TUIConfig) CurrentModelLabel() string {
|
||||
cur := cfg.Provider.Current
|
||||
if cur.Model == "" {
|
||||
return "(not configured)"
|
||||
}
|
||||
label := cur.Scheme
|
||||
if label != "" {
|
||||
label += " / "
|
||||
}
|
||||
return label + cur.Model
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
package configstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
configDirName = ".picoclaw"
|
||||
configFileName = "config.json"
|
||||
)
|
||||
|
||||
func ConfigPath() (string, error) {
|
||||
dir, err := ConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, configFileName), nil
|
||||
}
|
||||
|
||||
func ConfigDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, configDirName), nil
|
||||
}
|
||||
|
||||
func Load() (*picoclawconfig.Config, error) {
|
||||
path, err := ConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return picoclawconfig.LoadConfig(path)
|
||||
}
|
||||
|
||||
func Save(cfg *picoclawconfig.Config) error {
|
||||
if cfg == nil {
|
||||
return errors.New("config is nil")
|
||||
}
|
||||
path, err := ConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return picoclawconfig.SaveConfig(path, cfg)
|
||||
}
|
||||
|
|
@ -1,506 +0,0 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config"
|
||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
type appState struct {
|
||||
app *tview.Application
|
||||
pages *tview.Pages
|
||||
stack []string
|
||||
config *picoclawconfig.Config
|
||||
configPath string
|
||||
gatewayCmd *exec.Cmd
|
||||
menus map[string]*Menu
|
||||
original []byte
|
||||
hasOriginal bool
|
||||
backupPath string
|
||||
dirty bool
|
||||
logPath string
|
||||
}
|
||||
|
||||
func Run() error {
|
||||
applyStyles()
|
||||
cfg, err := configstore.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := configstore.ConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg == nil {
|
||||
cfg = picoclawconfig.DefaultConfig()
|
||||
}
|
||||
|
||||
originalData, hasOriginal := loadOriginalConfig(path)
|
||||
backupPath := path + ".bak"
|
||||
if hasOriginal {
|
||||
_ = writeBackupConfig(backupPath, originalData)
|
||||
}
|
||||
|
||||
logPath := filepath.Join(filepath.Dir(path), "gateway.log")
|
||||
state := &appState{
|
||||
app: tview.NewApplication(),
|
||||
pages: tview.NewPages(),
|
||||
config: cfg,
|
||||
configPath: path,
|
||||
menus: map[string]*Menu{},
|
||||
original: originalData,
|
||||
hasOriginal: hasOriginal,
|
||||
backupPath: backupPath,
|
||||
logPath: logPath,
|
||||
}
|
||||
|
||||
state.push("main", state.mainMenu())
|
||||
|
||||
root := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
root.AddItem(bannerView(), 6, 0, false)
|
||||
root.AddItem(state.pages, 0, 1, true)
|
||||
|
||||
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *appState) push(name string, primitive tview.Primitive) {
|
||||
s.pages.AddPage(name, primitive, true, true)
|
||||
s.stack = append(s.stack, name)
|
||||
s.pages.SwitchToPage(name)
|
||||
if menu, ok := primitive.(*Menu); ok {
|
||||
s.menus[name] = menu
|
||||
}
|
||||
}
|
||||
|
||||
func (s *appState) pop() {
|
||||
if len(s.stack) == 0 {
|
||||
return
|
||||
}
|
||||
last := s.stack[len(s.stack)-1]
|
||||
s.pages.RemovePage(last)
|
||||
s.stack = s.stack[:len(s.stack)-1]
|
||||
if len(s.stack) == 0 {
|
||||
s.app.Stop()
|
||||
return
|
||||
}
|
||||
current := s.stack[len(s.stack)-1]
|
||||
s.pages.SwitchToPage(current)
|
||||
if menu, ok := s.menus[current]; ok {
|
||||
s.refreshMenu(current, menu)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *appState) mainMenu() tview.Primitive {
|
||||
menu := NewMenu("Config Menu", nil)
|
||||
refreshMainMenu(menu, s)
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
case tcell.KeyEsc:
|
||||
s.requestExit()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'q' {
|
||||
s.requestExit()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
func (s *appState) refreshMenu(name string, menu *Menu) {
|
||||
switch name {
|
||||
case "main":
|
||||
refreshMainMenu(menu, s)
|
||||
case "model":
|
||||
refreshModelMenuFromState(menu, s)
|
||||
case "channel":
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshMainMenuIfPresent(s *appState) {
|
||||
if menu, ok := s.menus["main"]; ok {
|
||||
refreshMainMenu(menu, s)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshMainMenu(menu *Menu, s *appState) {
|
||||
selectedModel := s.selectedModelName()
|
||||
modelReady := selectedModel != ""
|
||||
channelReady := s.hasEnabledChannel()
|
||||
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
|
||||
|
||||
gatewayLabel := "Start Gateway"
|
||||
gatewayDescription := "Launch gateway for channels"
|
||||
if gatewayRunning {
|
||||
gatewayLabel = "Stop Gateway"
|
||||
gatewayDescription = "Gateway running"
|
||||
}
|
||||
|
||||
items := []MenuItem{
|
||||
{
|
||||
Label: rootModelLabel(selectedModel),
|
||||
Description: rootModelDescription(selectedModel),
|
||||
Action: func() {
|
||||
s.push("model", s.modelMenu())
|
||||
},
|
||||
MainColor: func() *tcell.Color {
|
||||
if modelReady {
|
||||
return nil
|
||||
}
|
||||
color := tcell.ColorGray
|
||||
return &color
|
||||
}(),
|
||||
},
|
||||
{
|
||||
Label: rootChannelLabel(channelReady),
|
||||
Description: rootChannelDescription(channelReady),
|
||||
Action: func() {
|
||||
s.push("channel", s.channelMenu())
|
||||
},
|
||||
MainColor: func() *tcell.Color {
|
||||
if channelReady {
|
||||
return nil
|
||||
}
|
||||
color := tcell.ColorGray
|
||||
return &color
|
||||
}(),
|
||||
},
|
||||
{
|
||||
Label: "Start Talk",
|
||||
Description: "Open picoclaw agent in terminal",
|
||||
Action: func() {
|
||||
s.requestStartTalk()
|
||||
},
|
||||
Disabled: !modelReady,
|
||||
},
|
||||
{
|
||||
Label: gatewayLabel,
|
||||
Description: gatewayDescription,
|
||||
Action: func() {
|
||||
if gatewayRunning {
|
||||
s.stopGateway()
|
||||
} else {
|
||||
s.requestStartGateway()
|
||||
}
|
||||
refreshMainMenu(menu, s)
|
||||
},
|
||||
Disabled: !gatewayRunning && (!modelReady || !channelReady),
|
||||
},
|
||||
{
|
||||
Label: "View Gateway Log",
|
||||
Description: "Open gateway.log",
|
||||
Action: func() {
|
||||
s.viewGatewayLog()
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Exit",
|
||||
Description: "Exit the TUI",
|
||||
Action: func() {
|
||||
s.requestExit()
|
||||
},
|
||||
},
|
||||
}
|
||||
menu.applyItems(items)
|
||||
}
|
||||
|
||||
func (s *appState) applyChangesValidated() bool {
|
||||
if err := s.config.ValidateModelList(); err != nil {
|
||||
s.showMessage("Validation failed", err.Error())
|
||||
return false
|
||||
}
|
||||
if err := s.validateAgentModel(); err != nil {
|
||||
s.showMessage("Validation failed", err.Error())
|
||||
return false
|
||||
}
|
||||
if err := configstore.Save(s.config); err != nil {
|
||||
s.showMessage("Save failed", err.Error())
|
||||
return false
|
||||
}
|
||||
if data, err := os.ReadFile(s.configPath); err == nil {
|
||||
s.original = data
|
||||
s.hasOriginal = true
|
||||
_ = writeBackupConfig(s.backupPath, data)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *appState) requestExit() {
|
||||
if s.dirty {
|
||||
s.confirmApplyOrDiscard(func() {
|
||||
s.app.Stop()
|
||||
}, func() {
|
||||
s.discardChanges()
|
||||
s.app.Stop()
|
||||
})
|
||||
return
|
||||
}
|
||||
s.app.Stop()
|
||||
}
|
||||
|
||||
func (s *appState) requestStartTalk() {
|
||||
if s.dirty {
|
||||
s.confirmApplyOrDiscard(func() {
|
||||
s.startTalk()
|
||||
}, func() {
|
||||
s.startTalk()
|
||||
})
|
||||
return
|
||||
}
|
||||
s.startTalk()
|
||||
}
|
||||
|
||||
func (s *appState) requestStartGateway() {
|
||||
if s.dirty {
|
||||
s.confirmApplyOrDiscard(func() {
|
||||
s.startGateway()
|
||||
}, func() {
|
||||
s.startGateway()
|
||||
})
|
||||
return
|
||||
}
|
||||
s.startGateway()
|
||||
}
|
||||
|
||||
func (s *appState) viewGatewayLog() {
|
||||
data, err := os.ReadFile(s.logPath)
|
||||
if err != nil {
|
||||
s.showMessage("Log not found", "gateway.log not found")
|
||||
return
|
||||
}
|
||||
text := tview.NewTextView()
|
||||
text.SetBorder(true).SetTitle("Gateway Log")
|
||||
text.SetText(string(data))
|
||||
text.SetDoneFunc(func(key tcell.Key) {
|
||||
s.pages.RemovePage("log")
|
||||
})
|
||||
text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
s.pages.RemovePage("log")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
s.pages.AddPage("log", text, true, true)
|
||||
}
|
||||
|
||||
func (s *appState) selectedModelName() string {
|
||||
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||
if modelName == "" {
|
||||
return ""
|
||||
}
|
||||
if !s.isActiveModelValid() {
|
||||
return ""
|
||||
}
|
||||
return modelName
|
||||
}
|
||||
|
||||
func rootModelLabel(selected string) string {
|
||||
if selected == "" {
|
||||
return "Model (no model selected)"
|
||||
}
|
||||
return "Model (" + selected + ")"
|
||||
}
|
||||
|
||||
func rootModelDescription(selected string) string {
|
||||
if selected == "" {
|
||||
return "no model selected"
|
||||
}
|
||||
return "selected"
|
||||
}
|
||||
|
||||
func rootChannelLabel(valid bool) string {
|
||||
if !valid {
|
||||
return "Channel (no channel enabled)"
|
||||
}
|
||||
return "Channel"
|
||||
}
|
||||
|
||||
func rootChannelDescription(valid bool) string {
|
||||
if !valid {
|
||||
return "no channel enabled"
|
||||
}
|
||||
return "enabled"
|
||||
}
|
||||
|
||||
func (s *appState) startTalk() {
|
||||
if !s.isActiveModelValid() {
|
||||
s.showMessage("Model required", "Select a valid model before starting talk")
|
||||
return
|
||||
}
|
||||
if !s.applyChangesValidated() {
|
||||
return
|
||||
}
|
||||
s.app.Suspend(func() {
|
||||
cmd := exec.Command("picoclaw", "agent")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
_ = cmd.Run()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *appState) startGateway() {
|
||||
if !s.isActiveModelValid() {
|
||||
s.showMessage("Model required", "Select a valid model before starting gateway")
|
||||
return
|
||||
}
|
||||
if !s.hasEnabledChannel() {
|
||||
s.showMessage("Channel required", "Enable at least one channel before starting gateway")
|
||||
return
|
||||
}
|
||||
if !s.applyChangesValidated() {
|
||||
return
|
||||
}
|
||||
_ = stopGatewayProcess()
|
||||
cmd := exec.Command("picoclaw", "gateway")
|
||||
logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
s.showMessage("Gateway failed", err.Error())
|
||||
return
|
||||
}
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
if err := cmd.Start(); err != nil {
|
||||
s.showMessage("Gateway failed", err.Error())
|
||||
_ = logFile.Close()
|
||||
return
|
||||
}
|
||||
_ = logFile.Close()
|
||||
s.gatewayCmd = cmd
|
||||
}
|
||||
|
||||
func (s *appState) stopGateway() {
|
||||
_ = stopGatewayProcess()
|
||||
if s.gatewayCmd != nil && s.gatewayCmd.Process != nil {
|
||||
_ = s.gatewayCmd.Process.Kill()
|
||||
}
|
||||
s.gatewayCmd = nil
|
||||
}
|
||||
|
||||
func (s *appState) isGatewayRunning() bool {
|
||||
return isGatewayProcessRunning()
|
||||
}
|
||||
|
||||
func (s *appState) validateAgentModel() error {
|
||||
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||
if modelName == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := s.config.GetModelConfig(modelName)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *appState) isActiveModelValid() bool {
|
||||
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||
if modelName == "" {
|
||||
return false
|
||||
}
|
||||
cfg, err := s.config.GetModelConfig(modelName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth"
|
||||
hasModel := strings.TrimSpace(cfg.Model) != ""
|
||||
return hasKey && hasModel
|
||||
}
|
||||
|
||||
func (s *appState) hasEnabledChannel() bool {
|
||||
c := s.config.Channels
|
||||
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
|
||||
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
|
||||
c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
|
||||
}
|
||||
|
||||
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {
|
||||
if s.pages.HasPage("apply") {
|
||||
return
|
||||
}
|
||||
modal := tview.NewModal().
|
||||
SetText("Apply changes or discard before continuing?").
|
||||
AddButtons([]string{"Cancel", "Discard", "Apply"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
s.pages.RemovePage("apply")
|
||||
switch buttonLabel {
|
||||
case "Discard":
|
||||
s.discardChanges()
|
||||
if onDiscard != nil {
|
||||
onDiscard()
|
||||
}
|
||||
case "Apply":
|
||||
if s.applyChangesValidated() {
|
||||
s.dirty = false
|
||||
if onApply != nil {
|
||||
onApply()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
modal.SetBorder(true)
|
||||
s.pages.AddPage("apply", modal, true, true)
|
||||
}
|
||||
|
||||
func (s *appState) discardChanges() {
|
||||
if s.hasOriginal {
|
||||
_ = writeOriginalConfig(s.configPath, s.original)
|
||||
} else {
|
||||
_ = os.Remove(s.configPath)
|
||||
}
|
||||
_ = os.Remove(s.backupPath)
|
||||
if cfg, err := configstore.Load(); err == nil && cfg != nil {
|
||||
s.config = cfg
|
||||
}
|
||||
s.dirty = false
|
||||
refreshMainMenuIfPresent(s)
|
||||
}
|
||||
|
||||
func (s *appState) showMessage(title, message string) {
|
||||
if s.pages.HasPage("message") {
|
||||
return
|
||||
}
|
||||
modal := tview.NewModal().
|
||||
SetText(strings.TrimSpace(message)).
|
||||
AddButtons([]string{"OK"}).
|
||||
SetDoneFunc(func(_ int, _ string) {
|
||||
s.pages.RemovePage("message")
|
||||
})
|
||||
modal.SetTitle(title).SetBorder(true)
|
||||
modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
|
||||
modal.SetTextColor(tview.Styles.PrimaryTextColor)
|
||||
modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255))
|
||||
modal.SetButtonTextColor(tview.Styles.PrimaryTextColor)
|
||||
s.pages.AddPage("message", modal, true, true)
|
||||
}
|
||||
|
||||
func loadOriginalConfig(path string) ([]byte, bool) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
func writeOriginalConfig(path string, data []byte) error {
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
func writeBackupConfig(path string, data []byte) error {
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
|
@ -1,574 +0,0 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func (s *appState) channelMenu() tview.Primitive {
|
||||
items := []MenuItem{
|
||||
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||
channelItem(
|
||||
"Telegram",
|
||||
"Telegram bot settings",
|
||||
s.config.Channels.Telegram.Enabled,
|
||||
func() { s.push("channel-telegram", s.telegramForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"Discord",
|
||||
"Discord bot settings",
|
||||
s.config.Channels.Discord.Enabled,
|
||||
func() { s.push("channel-discord", s.discordForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"QQ",
|
||||
"QQ bot settings",
|
||||
s.config.Channels.QQ.Enabled,
|
||||
func() { s.push("channel-qq", s.qqForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"MaixCam",
|
||||
"MaixCam gateway",
|
||||
s.config.Channels.MaixCam.Enabled,
|
||||
func() { s.push("channel-maixcam", s.maixcamForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"WhatsApp",
|
||||
"WhatsApp bridge",
|
||||
s.config.Channels.WhatsApp.Enabled,
|
||||
func() { s.push("channel-whatsapp", s.whatsappForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"Feishu",
|
||||
"Feishu bot settings",
|
||||
s.config.Channels.Feishu.Enabled,
|
||||
func() { s.push("channel-feishu", s.feishuForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"DingTalk",
|
||||
"DingTalk bot settings",
|
||||
s.config.Channels.DingTalk.Enabled,
|
||||
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"Slack",
|
||||
"Slack bot settings",
|
||||
s.config.Channels.Slack.Enabled,
|
||||
func() { s.push("channel-slack", s.slackForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"LINE",
|
||||
"LINE bot settings",
|
||||
s.config.Channels.LINE.Enabled,
|
||||
func() { s.push("channel-line", s.lineForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"OneBot",
|
||||
"OneBot settings",
|
||||
s.config.Channels.OneBot.Enabled,
|
||||
func() { s.push("channel-onebot", s.onebotForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"WeCom",
|
||||
"WeCom bot settings",
|
||||
s.config.Channels.WeCom.Enabled,
|
||||
func() { s.push("channel-wecom", s.wecomForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"WeCom App",
|
||||
"WeCom App settings",
|
||||
s.config.Channels.WeComApp.Enabled,
|
||||
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
|
||||
),
|
||||
}
|
||||
|
||||
menu := NewMenu("Channels", items)
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'q' {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
return menu
|
||||
}
|
||||
|
||||
func refreshChannelMenuFromState(menu *Menu, s *appState) {
|
||||
items := []MenuItem{
|
||||
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||
channelItem(
|
||||
"Telegram",
|
||||
"Telegram bot settings",
|
||||
s.config.Channels.Telegram.Enabled,
|
||||
func() { s.push("channel-telegram", s.telegramForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"Discord",
|
||||
"Discord bot settings",
|
||||
s.config.Channels.Discord.Enabled,
|
||||
func() { s.push("channel-discord", s.discordForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"QQ",
|
||||
"QQ bot settings",
|
||||
s.config.Channels.QQ.Enabled,
|
||||
func() { s.push("channel-qq", s.qqForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"MaixCam",
|
||||
"MaixCam gateway",
|
||||
s.config.Channels.MaixCam.Enabled,
|
||||
func() { s.push("channel-maixcam", s.maixcamForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"WhatsApp",
|
||||
"WhatsApp bridge",
|
||||
s.config.Channels.WhatsApp.Enabled,
|
||||
func() { s.push("channel-whatsapp", s.whatsappForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"Feishu",
|
||||
"Feishu bot settings",
|
||||
s.config.Channels.Feishu.Enabled,
|
||||
func() { s.push("channel-feishu", s.feishuForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"DingTalk",
|
||||
"DingTalk bot settings",
|
||||
s.config.Channels.DingTalk.Enabled,
|
||||
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"Slack",
|
||||
"Slack bot settings",
|
||||
s.config.Channels.Slack.Enabled,
|
||||
func() { s.push("channel-slack", s.slackForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"LINE",
|
||||
"LINE bot settings",
|
||||
s.config.Channels.LINE.Enabled,
|
||||
func() { s.push("channel-line", s.lineForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"OneBot",
|
||||
"OneBot settings",
|
||||
s.config.Channels.OneBot.Enabled,
|
||||
func() { s.push("channel-onebot", s.onebotForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"WeCom",
|
||||
"WeCom bot settings",
|
||||
s.config.Channels.WeCom.Enabled,
|
||||
func() { s.push("channel-wecom", s.wecomForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"WeCom App",
|
||||
"WeCom App settings",
|
||||
s.config.Channels.WeComApp.Enabled,
|
||||
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
|
||||
),
|
||||
}
|
||||
menu.applyItems(items)
|
||||
}
|
||||
|
||||
func (s *appState) telegramForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Telegram
|
||||
form := baseChannelForm("Telegram", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||
cfg.Token = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) {
|
||||
cfg.Proxy = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) discordForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Discord
|
||||
form := baseChannelForm("Discord", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||
cfg.Token = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) {
|
||||
cfg.MentionOnly = checked
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) qqForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.QQ
|
||||
form := baseChannelForm("QQ", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
||||
cfg.AppID = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
||||
cfg.AppSecret = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) maixcamForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.MaixCam
|
||||
form := baseChannelForm("MaixCam", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Host", cfg.Host, 64, nil, func(text string) {
|
||||
cfg.Host = strings.TrimSpace(text)
|
||||
})
|
||||
addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value })
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) whatsappForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.WhatsApp
|
||||
form := baseChannelForm("WhatsApp", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) {
|
||||
cfg.BridgeURL = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) feishuForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Feishu
|
||||
form := baseChannelForm("Feishu", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
||||
cfg.AppID = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
||||
cfg.AppSecret = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) {
|
||||
cfg.EncryptKey = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) {
|
||||
cfg.VerificationToken = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) dingtalkForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.DingTalk
|
||||
form := baseChannelForm("DingTalk", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) {
|
||||
cfg.ClientID = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) {
|
||||
cfg.ClientSecret = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) slackForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Slack
|
||||
form := baseChannelForm("Slack", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) {
|
||||
cfg.BotToken = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) {
|
||||
cfg.AppToken = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) lineForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.LINE
|
||||
form := baseChannelForm("LINE", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) {
|
||||
cfg.ChannelSecret = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) {
|
||||
cfg.ChannelAccessToken = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||
cfg.WebhookHost = strings.TrimSpace(text)
|
||||
})
|
||||
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||
cfg.WebhookPath = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) onebotForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.OneBot
|
||||
form := baseChannelForm("OneBot", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) {
|
||||
cfg.WSUrl = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
|
||||
cfg.AccessToken = strings.TrimSpace(text)
|
||||
})
|
||||
addIntField(
|
||||
form,
|
||||
"Reconnect Interval",
|
||||
cfg.ReconnectInterval,
|
||||
func(value int) { cfg.ReconnectInterval = value },
|
||||
)
|
||||
form.AddInputField(
|
||||
"Group Trigger Prefix",
|
||||
strings.Join(cfg.GroupTriggerPrefix, ","),
|
||||
128,
|
||||
nil,
|
||||
func(text string) {
|
||||
cfg.GroupTriggerPrefix = splitCSV(text)
|
||||
},
|
||||
)
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) wecomForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.WeCom
|
||||
form := baseChannelForm("WeCom", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||
cfg.Token = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
||||
cfg.EncodingAESKey = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) {
|
||||
cfg.WebhookURL = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||
cfg.WebhookHost = strings.TrimSpace(text)
|
||||
})
|
||||
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||
cfg.WebhookPath = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
addIntField(
|
||||
form,
|
||||
"Reply Timeout",
|
||||
cfg.ReplyTimeout,
|
||||
func(value int) { cfg.ReplyTimeout = value },
|
||||
)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) wecomAppForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.WeComApp
|
||||
form := baseChannelForm("WeCom App", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) {
|
||||
cfg.CorpID = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) {
|
||||
cfg.CorpSecret = strings.TrimSpace(text)
|
||||
})
|
||||
addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value })
|
||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||
cfg.Token = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
||||
cfg.EncodingAESKey = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||
cfg.WebhookHost = strings.TrimSpace(text)
|
||||
})
|
||||
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||
cfg.WebhookPath = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
addIntField(
|
||||
form,
|
||||
"Reply Timeout",
|
||||
cfg.ReplyTimeout,
|
||||
func(value int) { cfg.ReplyTimeout = value },
|
||||
)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form {
|
||||
form := tview.NewForm()
|
||||
form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title))
|
||||
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
||||
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
||||
form.AddCheckbox("Enabled", enabled, func(checked bool) {
|
||||
onEnabled(checked)
|
||||
})
|
||||
return form
|
||||
}
|
||||
|
||||
func wrapWithBack(form *tview.Form, s *appState) tview.Primitive {
|
||||
form.AddButton("Back", func() {
|
||||
s.pop()
|
||||
})
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
return form
|
||||
}
|
||||
|
||||
func splitCSV(input string) picoclawconfig.FlexibleStringSlice {
|
||||
parts := strings.Split(strings.TrimSpace(input), ",")
|
||||
cleaned := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
value := strings.TrimSpace(part)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, value)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func addIntField(form *tview.Form, label string, value int, onChange func(int)) {
|
||||
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||
var parsed int
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||
onChange(parsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) {
|
||||
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||
var parsed int64
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||
onChange(parsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func channelItem(label, description string, enabled bool, action MenuAction) MenuItem {
|
||||
item := MenuItem{
|
||||
Label: label,
|
||||
Description: description,
|
||||
Action: action,
|
||||
}
|
||||
if !enabled {
|
||||
color := tcell.ColorGray
|
||||
item.MainColor = &color
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package ui
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func isGatewayProcessRunning() bool {
|
||||
cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
func stopGatewayProcess() error {
|
||||
cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
|
||||
return cmd.Run()
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package ui
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func isGatewayProcessRunning() bool {
|
||||
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
func stopGatewayProcess() error {
|
||||
cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe")
|
||||
return cmd.Run()
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
type MenuAction func()
|
||||
|
||||
type MenuItem struct {
|
||||
Label string
|
||||
Description string
|
||||
Action MenuAction
|
||||
Disabled bool
|
||||
MainColor *tcell.Color
|
||||
DescColor *tcell.Color
|
||||
}
|
||||
|
||||
type Menu struct {
|
||||
*tview.Table
|
||||
items []MenuItem
|
||||
}
|
||||
|
||||
func NewMenu(title string, items []MenuItem) *Menu {
|
||||
table := tview.NewTable().SetSelectable(true, false)
|
||||
table.SetBorder(true).SetTitle(title)
|
||||
table.SetBorders(false)
|
||||
menu := &Menu{Table: table, items: items}
|
||||
menu.applyItems(items)
|
||||
menu.SetSelectedFunc(func(row, _ int) {
|
||||
if row < 0 || row >= len(menu.items) {
|
||||
return
|
||||
}
|
||||
item := menu.items[row]
|
||||
if item.Disabled || item.Action == nil {
|
||||
return
|
||||
}
|
||||
item.Action()
|
||||
})
|
||||
menu.SetSelectedStyle(
|
||||
tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor).
|
||||
Background(tcell.NewRGBColor(189, 147, 249)),
|
||||
)
|
||||
return menu
|
||||
}
|
||||
|
||||
func (m *Menu) applyItems(items []MenuItem) {
|
||||
m.items = items
|
||||
m.Clear()
|
||||
for row, item := range items {
|
||||
label := item.Label
|
||||
if item.Disabled && label != "" {
|
||||
label = label + " (disabled)"
|
||||
}
|
||||
left := tview.NewTableCell(label)
|
||||
right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight)
|
||||
if item.MainColor != nil {
|
||||
left.SetTextColor(*item.MainColor)
|
||||
}
|
||||
if item.DescColor != nil {
|
||||
right.SetTextColor(*item.DescColor)
|
||||
} else {
|
||||
right.SetTextColor(tview.Styles.TertiaryTextColor)
|
||||
}
|
||||
if item.Disabled {
|
||||
left.SetTextColor(tcell.ColorGray)
|
||||
right.SetTextColor(tcell.ColorGray)
|
||||
}
|
||||
m.SetCell(row, 0, left)
|
||||
m.SetCell(row, 1, right)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,343 +0,0 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func (s *appState) modelMenu() tview.Primitive {
|
||||
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
||||
items = append(items,
|
||||
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||
MenuItem{
|
||||
Label: "Add model",
|
||||
Description: "Append a new model entry",
|
||||
Action: func() {
|
||||
s.addModel(
|
||||
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
||||
)
|
||||
s.push(
|
||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||
s.modelForm(len(s.config.ModelList)-1),
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||
for i := range s.config.ModelList {
|
||||
index := i
|
||||
model := s.config.ModelList[i]
|
||||
isValid := isModelValid(model)
|
||||
desc := model.APIBase
|
||||
if desc == "" {
|
||||
desc = model.AuthMethod
|
||||
}
|
||||
if desc == "" {
|
||||
desc = "api_key required"
|
||||
}
|
||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||
if model.ModelName == currentModel && currentModel != "" {
|
||||
label = "* " + label
|
||||
}
|
||||
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||
items = append(items, MenuItem{
|
||||
Label: label,
|
||||
Description: desc,
|
||||
MainColor: modelStatusColor(isValid, isSelected),
|
||||
Action: func() {
|
||||
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
menu := NewMenu("Models", items)
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'q' {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == ' ' {
|
||||
row, _ := menu.GetSelection()
|
||||
if row > 0 && row <= len(s.config.ModelList) {
|
||||
model := s.config.ModelList[row-1]
|
||||
if !isModelValid(model) {
|
||||
s.showMessage(
|
||||
"Invalid model",
|
||||
"Select a model with api_key or oauth auth_method",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
s.config.Agents.Defaults.Model = model.ModelName
|
||||
s.dirty = true
|
||||
refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList)
|
||||
refreshMainMenuIfPresent(s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
return menu
|
||||
}
|
||||
|
||||
func (s *appState) modelForm(index int) tview.Primitive {
|
||||
model := &s.config.ModelList[index]
|
||||
form := tview.NewForm()
|
||||
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
||||
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
||||
|
||||
addInput(form, "Model Name", model.ModelName, func(value string) {
|
||||
model.ModelName = value
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["model"]; ok {
|
||||
refreshModelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
addInput(form, "Model", model.Model, func(value string) {
|
||||
model.Model = value
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["model"]; ok {
|
||||
refreshModelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
addInput(form, "API Base", model.APIBase, func(value string) {
|
||||
model.APIBase = value
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["model"]; ok {
|
||||
refreshModelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
addInput(form, "API Key", model.APIKey, func(value string) {
|
||||
model.APIKey = value
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["model"]; ok {
|
||||
refreshModelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
addInput(form, "Proxy", model.Proxy, func(value string) {
|
||||
model.Proxy = value
|
||||
})
|
||||
addInput(form, "Auth Method", model.AuthMethod, func(value string) {
|
||||
model.AuthMethod = value
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["model"]; ok {
|
||||
refreshModelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
addInput(form, "Connect Mode", model.ConnectMode, func(value string) {
|
||||
model.ConnectMode = value
|
||||
})
|
||||
addInput(form, "Workspace", model.Workspace, func(value string) {
|
||||
model.Workspace = value
|
||||
})
|
||||
addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) {
|
||||
model.MaxTokensField = value
|
||||
})
|
||||
addIntInput(form, "RPM", model.RPM, func(value int) {
|
||||
model.RPM = value
|
||||
})
|
||||
addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) {
|
||||
model.RequestTimeout = value
|
||||
})
|
||||
|
||||
form.AddButton("Delete", func() {
|
||||
s.deleteModel(index)
|
||||
})
|
||||
form.AddButton("Test", func() {
|
||||
s.testModel(model)
|
||||
})
|
||||
form.AddButton("Back", func() {
|
||||
s.pop()
|
||||
})
|
||||
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
return form
|
||||
}
|
||||
|
||||
func addInput(form *tview.Form, label, value string, onChange func(string)) {
|
||||
form.AddInputField(label, value, 128, nil, func(text string) {
|
||||
onChange(strings.TrimSpace(text))
|
||||
})
|
||||
}
|
||||
|
||||
func addIntInput(form *tview.Form, label string, value int, onChange func(int)) {
|
||||
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||
var parsed int
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||
onChange(parsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *appState) addModel(model picoclawconfig.ModelConfig) {
|
||||
s.config.ModelList = append(s.config.ModelList, model)
|
||||
}
|
||||
|
||||
func (s *appState) deleteModel(index int) {
|
||||
if index < 0 || index >= len(s.config.ModelList) {
|
||||
return
|
||||
}
|
||||
s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...)
|
||||
s.pop()
|
||||
}
|
||||
|
||||
func modelStatusColor(valid bool, selected bool) *tcell.Color {
|
||||
if valid {
|
||||
color := tview.Styles.PrimaryTextColor
|
||||
return &color
|
||||
}
|
||||
color := tcell.ColorGray
|
||||
return &color
|
||||
}
|
||||
|
||||
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
||||
for i, model := range models {
|
||||
row := i + 1
|
||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||
isValid := isModelValid(model)
|
||||
if model.ModelName == currentModel && currentModel != "" {
|
||||
label = "* " + label
|
||||
}
|
||||
cell := menu.GetCell(row, 0)
|
||||
if cell != nil {
|
||||
cell.SetText(label)
|
||||
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||
color := modelStatusColor(isValid, isSelected)
|
||||
if color != nil {
|
||||
cell.SetTextColor(*color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
||||
items = append(items,
|
||||
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||
MenuItem{
|
||||
Label: "Add model",
|
||||
Description: "Append a new model entry",
|
||||
Action: func() {
|
||||
s.addModel(
|
||||
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
||||
)
|
||||
s.push(
|
||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||
s.modelForm(len(s.config.ModelList)-1),
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||
for i := range s.config.ModelList {
|
||||
index := i
|
||||
model := s.config.ModelList[i]
|
||||
isValid := isModelValid(model)
|
||||
desc := model.APIBase
|
||||
if desc == "" {
|
||||
desc = model.AuthMethod
|
||||
}
|
||||
if desc == "" {
|
||||
desc = "api_key required"
|
||||
}
|
||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||
if model.ModelName == currentModel && currentModel != "" {
|
||||
label = "* " + label
|
||||
}
|
||||
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||
items = append(items, MenuItem{
|
||||
Label: label,
|
||||
Description: desc,
|
||||
MainColor: modelStatusColor(isValid, isSelected),
|
||||
Action: func() {
|
||||
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
|
||||
},
|
||||
})
|
||||
}
|
||||
menu.applyItems(items)
|
||||
}
|
||||
|
||||
func isModelValid(model picoclawconfig.ModelConfig) bool {
|
||||
hasKey := strings.TrimSpace(model.APIKey) != "" ||
|
||||
strings.TrimSpace(model.AuthMethod) == "oauth"
|
||||
hasModel := strings.TrimSpace(model.Model) != ""
|
||||
return hasKey && hasModel
|
||||
}
|
||||
|
||||
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
||||
if model == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(model.APIKey) == "" {
|
||||
s.showMessage("Missing API Key", "Set api_key before testing")
|
||||
return
|
||||
}
|
||||
base := strings.TrimSpace(model.APIBase)
|
||||
if base == "" {
|
||||
s.showMessage("Missing API Base", "Set api_base before testing")
|
||||
return
|
||||
}
|
||||
modelID := strings.TrimSpace(model.Model)
|
||||
if modelID == "" {
|
||||
s.showMessage("Missing Model", "Set model before testing")
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(modelID, "openai/") {
|
||||
s.showMessage("Unsupported model", "Only openai/* models are supported for test")
|
||||
return
|
||||
}
|
||||
modelName := strings.TrimPrefix(modelID, "openai/")
|
||||
endpoint := strings.TrimRight(base, "/") + "/chat/completions"
|
||||
|
||||
payload := fmt.Sprintf(
|
||||
`{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`,
|
||||
modelName,
|
||||
)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload))
|
||||
if err != nil {
|
||||
s.showMessage("Test failed", err.Error())
|
||||
return
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey))
|
||||
|
||||
resp, err := client.Do(request)
|
||||
if err != nil {
|
||||
s.showMessage("Test failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
s.showMessage("Test OK", resp.Status)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
s.showMessage(
|
||||
"Test failed",
|
||||
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
func applyStyles() {
|
||||
tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22)
|
||||
tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53)
|
||||
tview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32)
|
||||
tview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255)
|
||||
tview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198)
|
||||
tview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253)
|
||||
tview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255)
|
||||
tview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123)
|
||||
tview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253)
|
||||
tview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22)
|
||||
tview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249)
|
||||
}
|
||||
|
||||
func bannerView() *tview.TextView {
|
||||
text := tview.NewTextView()
|
||||
text.SetDynamicColors(true)
|
||||
text.SetTextAlign(tview.AlignCenter)
|
||||
text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
|
||||
text.SetText(
|
||||
"[::b][#84aaff]██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
||||
"[#84aaff]██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
||||
"[#84aaff]██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
|
||||
"[#84aaff]██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
|
||||
"[#84aaff]██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
||||
"[#84aaff]╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝",
|
||||
)
|
||||
text.SetBorder(false)
|
||||
return text
|
||||
}
|
||||
|
|
@ -1,15 +1,48 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui"
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := ui.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
configPath := tuicfg.DefaultConfigPath()
|
||||
if len(os.Args) > 1 {
|
||||
configPath = os.Args[1]
|
||||
}
|
||||
|
||||
configDir := filepath.Dir(configPath)
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
cmd := exec.Command("picoclaw", "onboard")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
_ = cmd.Run()
|
||||
}
|
||||
|
||||
cfg, err := tuicfg.Load(configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
app := ui.New(cfg, configPath)
|
||||
// Bind model selection hook to sync to main config
|
||||
app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) {
|
||||
_ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID)
|
||||
}
|
||||
if err := app.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
325
cmd/picoclaw-launcher-tui/ui/app.go
Normal file
325
cmd/picoclaw-launcher-tui/ui/app.go
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
)
|
||||
|
||||
// App is the root TUI application.
|
||||
type App struct {
|
||||
tapp *tview.Application
|
||||
pages *tview.Pages
|
||||
pageStack []string
|
||||
cfg *tuicfg.TUIConfig
|
||||
configPath string
|
||||
pageRefreshFns map[string]func()
|
||||
headerModelTV *tview.TextView
|
||||
modalOpen map[string]bool
|
||||
|
||||
// OnModelSelected is called when a model is selected in the UI.
|
||||
// Can be nil to disable.
|
||||
OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string)
|
||||
|
||||
modelCache map[string][]modelEntry
|
||||
modelCacheMu sync.RWMutex
|
||||
refreshMu sync.Mutex
|
||||
}
|
||||
|
||||
// cacheKey returns the map key for a (scheme, user) pair.
|
||||
func cacheKey(schemeName, userName string) string {
|
||||
return fmt.Sprintf("%s/%s", schemeName, userName)
|
||||
}
|
||||
|
||||
// cachedModels returns a defensive copy of the cached model list for a user (may be nil).
|
||||
func (a *App) cachedModels(schemeName, userName string) []modelEntry {
|
||||
a.modelCacheMu.RLock()
|
||||
defer a.modelCacheMu.RUnlock()
|
||||
entries := a.modelCache[cacheKey(schemeName, userName)]
|
||||
return append([]modelEntry(nil), entries...)
|
||||
}
|
||||
|
||||
// refreshModelCache fetches models for every user in the config concurrently.
|
||||
// Serialized by refreshMu so concurrent calls don't race on the cache map.
|
||||
// When all fetches complete it calls onDone via QueueUpdateDraw.
|
||||
func (a *App) refreshModelCache(onDone func()) {
|
||||
go func() {
|
||||
a.refreshMu.Lock()
|
||||
defer a.refreshMu.Unlock()
|
||||
|
||||
users := a.cfg.Provider.Users
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
|
||||
schemeURL := make(map[string]string, len(schemes))
|
||||
for _, s := range schemes {
|
||||
schemeURL[s.Name] = s.BaseURL
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, u := range users {
|
||||
baseURL, ok := schemeURL[u.Scheme]
|
||||
if !ok || baseURL == "" {
|
||||
continue
|
||||
}
|
||||
if u.Key == "" {
|
||||
a.modelCacheMu.Lock()
|
||||
if a.modelCache == nil {
|
||||
a.modelCache = make(map[string][]modelEntry)
|
||||
}
|
||||
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
|
||||
a.modelCacheMu.Unlock()
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
bURL := baseURL
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
entries, err := fetchModels(bURL, u.Key)
|
||||
a.modelCacheMu.Lock()
|
||||
if a.modelCache == nil {
|
||||
a.modelCache = make(map[string][]modelEntry)
|
||||
}
|
||||
if err != nil || len(entries) == 0 {
|
||||
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
|
||||
} else {
|
||||
a.modelCache[cacheKey(u.Scheme, u.Name)] = entries
|
||||
}
|
||||
a.modelCacheMu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if onDone != nil {
|
||||
a.tapp.QueueUpdateDraw(onDone)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// New creates and wires up the TUI application.
|
||||
func New(cfg *tuicfg.TUIConfig, configPath string) *App {
|
||||
// Cyberpunk Theme Colors
|
||||
// Dark background
|
||||
tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void
|
||||
tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo
|
||||
tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40)
|
||||
|
||||
// Borders and Titles
|
||||
tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||
tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||
tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
|
||||
|
||||
// Text
|
||||
tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white
|
||||
tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||
tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime
|
||||
tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black
|
||||
tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
|
||||
|
||||
a := &App{
|
||||
tapp: tview.NewApplication(),
|
||||
pages: tview.NewPages(),
|
||||
pageStack: []string{},
|
||||
cfg: cfg,
|
||||
configPath: configPath,
|
||||
pageRefreshFns: make(map[string]func()),
|
||||
modalOpen: make(map[string]bool),
|
||||
}
|
||||
|
||||
a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
if len(a.modalOpen) > 0 {
|
||||
return event
|
||||
}
|
||||
return a.goBack()
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.buildPages()
|
||||
return a
|
||||
}
|
||||
|
||||
// Run starts the TUI event loop.
|
||||
func (a *App) Run() error {
|
||||
return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run()
|
||||
}
|
||||
|
||||
func (a *App) buildPages() {
|
||||
a.pages.AddPage("home", a.newHomePage(), true, true)
|
||||
a.pageStack = []string{"home"}
|
||||
}
|
||||
|
||||
func (a *App) navigateTo(name string, page tview.Primitive) {
|
||||
a.pages.RemovePage(name)
|
||||
a.pages.AddPage(name, page, true, false)
|
||||
a.pageStack = append(a.pageStack, name)
|
||||
a.pages.SwitchToPage(name)
|
||||
}
|
||||
|
||||
func (a *App) goBack() *tcell.EventKey {
|
||||
if len(a.pageStack) <= 1 {
|
||||
return nil
|
||||
}
|
||||
popped := a.pageStack[len(a.pageStack)-1]
|
||||
a.pageStack = a.pageStack[:len(a.pageStack)-1]
|
||||
a.pages.RemovePage(popped)
|
||||
prev := a.pageStack[len(a.pageStack)-1]
|
||||
if fn, ok := a.pageRefreshFns[prev]; ok {
|
||||
fn()
|
||||
}
|
||||
if prev == "home" && a.headerModelTV != nil {
|
||||
a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ")
|
||||
}
|
||||
a.pages.SwitchToPage(prev)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) showModal(name string, primitive tview.Primitive) {
|
||||
a.modalOpen[name] = true
|
||||
a.pages.AddPage(name, primitive, true, true)
|
||||
}
|
||||
|
||||
func (a *App) hideModal(name string) {
|
||||
delete(a.modalOpen, name)
|
||||
a.pages.HidePage(name)
|
||||
a.pages.RemovePage(name)
|
||||
}
|
||||
|
||||
func (a *App) save() {
|
||||
if err := tuicfg.Save(a.configPath, a.cfg); err != nil {
|
||||
a.showError("save failed: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) showError(msg string) {
|
||||
modal := tview.NewModal().
|
||||
SetText(" [red::b]ERROR[-::-]\n\n" + msg).
|
||||
AddButtons([]string{"OK"}).
|
||||
SetDoneFunc(func(_ int, _ string) {
|
||||
a.hideModal("error")
|
||||
})
|
||||
// Cyberpunk Modal Style
|
||||
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red
|
||||
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||
a.showModal("error", modal)
|
||||
}
|
||||
|
||||
func (a *App) confirmDelete(label string, onConfirm func()) {
|
||||
modal := tview.NewModal().
|
||||
SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]").
|
||||
AddButtons([]string{"Delete", "Cancel"}).
|
||||
SetDoneFunc(func(_ int, buttonLabel string) {
|
||||
a.hideModal("confirm-delete")
|
||||
if buttonLabel == "Delete" {
|
||||
onConfirm()
|
||||
}
|
||||
})
|
||||
// Cyberpunk Modal Style
|
||||
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger
|
||||
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||
a.showModal("confirm-delete", modal)
|
||||
}
|
||||
|
||||
func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive {
|
||||
return tview.NewFlex().
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(form, height, 1, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false)
|
||||
}
|
||||
|
||||
func hintBar(text string) *tview.TextView {
|
||||
tv := tview.NewTextView().
|
||||
SetText(text).
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan
|
||||
tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo
|
||||
return tv
|
||||
}
|
||||
|
||||
func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive {
|
||||
var modelTV *tview.TextView
|
||||
if pageID == "home" {
|
||||
if a.headerModelTV == nil {
|
||||
a.headerModelTV = tview.NewTextView()
|
||||
a.headerModelTV.SetTextAlign(tview.AlignRight).
|
||||
SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime
|
||||
SetDynamicColors(true).
|
||||
SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
}
|
||||
modelTV = a.headerModelTV
|
||||
modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ")
|
||||
} else {
|
||||
modelTV = tview.NewTextView()
|
||||
modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
}
|
||||
|
||||
headerLeft := tview.NewTextView().
|
||||
SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///").
|
||||
SetDynamicColors(true).
|
||||
SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
header := tview.NewFlex().
|
||||
AddItem(headerLeft, 0, 1, false).
|
||||
AddItem(modelTV, 0, 1, false)
|
||||
|
||||
sidebar := tview.NewTextView().
|
||||
SetDynamicColors(true).
|
||||
SetWrap(false)
|
||||
sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||
|
||||
// Cyberpunk Sidebar Styling
|
||||
activePrefix := "[#39ff14::b]>> " // Neon Lime arrow
|
||||
activeSuffix := "[-]"
|
||||
inactivePrefix := "[#808080] "
|
||||
inactiveSuffix := "[-]"
|
||||
|
||||
sbText := "\n\n" // Top padding
|
||||
|
||||
menuItem := func(id, label string) string {
|
||||
if pageID == id {
|
||||
return activePrefix + label + activeSuffix + "\n\n"
|
||||
}
|
||||
return inactivePrefix + label + inactiveSuffix + "\n\n"
|
||||
}
|
||||
|
||||
sbText += menuItem("home", "HOME")
|
||||
sbText += menuItem("schemes", "SCHEMES")
|
||||
sbText += menuItem("users", "USERS")
|
||||
sbText += menuItem("models", "MODELS")
|
||||
sbText += menuItem("channels", "CHANNELS")
|
||||
sbText += menuItem("gateway", "GATEWAY")
|
||||
|
||||
sidebar.SetText(sbText)
|
||||
|
||||
footer := hintBar(hint)
|
||||
|
||||
grid := tview.NewGrid().
|
||||
SetRows(1, 0, 1).
|
||||
SetColumns(20, 0). // Slightly wider sidebar
|
||||
AddItem(header, 0, 0, 1, 2, 0, 0, false).
|
||||
AddItem(sidebar, 1, 0, 1, 1, 0, 0, false).
|
||||
AddItem(content, 1, 1, 1, 1, 0, 0, true).
|
||||
AddItem(footer, 2, 0, 1, 2, 0, 0, false)
|
||||
|
||||
// Add a border around the content area if possible, or ensure content has its own border
|
||||
// grid.SetBorders(false) // Grid borders usually look bad, handled by components
|
||||
|
||||
return grid
|
||||
}
|
||||
202
cmd/picoclaw-launcher-tui/ui/channels.go
Normal file
202
cmd/picoclaw-launcher-tui/ui/channels.go
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
func (a *App) newChannelsPage() tview.Primitive {
|
||||
list := tview.NewList()
|
||||
list.SetBorder(true).
|
||||
SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
|
||||
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
|
||||
list.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)),
|
||||
)
|
||||
list.SetHighlightFullLine(true)
|
||||
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
rebuild := func() {
|
||||
sel := list.GetCurrentItem()
|
||||
list.Clear()
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
configPath := filepath.Join(home, ".picoclaw", "config.json")
|
||||
|
||||
var cfg map[string]any
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
_ = json.Unmarshal(data, &cfg)
|
||||
}
|
||||
|
||||
if chRaw, ok := cfg["channels"].(map[string]any); ok {
|
||||
for name, ch := range chRaw {
|
||||
chMap, ok := ch.(map[string]any)
|
||||
enabled := "disabled"
|
||||
if ok {
|
||||
if e, ok := chMap["enabled"].(bool); ok && e {
|
||||
enabled = "enabled"
|
||||
}
|
||||
}
|
||||
list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() {
|
||||
a.showChannelEditForm(configPath, name, chMap)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if sel >= 0 && sel < list.GetItemCount() {
|
||||
list.SetCurrentItem(sel)
|
||||
}
|
||||
}
|
||||
rebuild()
|
||||
|
||||
a.pageRefreshFns["channels"] = rebuild
|
||||
|
||||
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
return a.goBack()
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ")
|
||||
}
|
||||
|
||||
func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) {
|
||||
form := tview.NewForm()
|
||||
form.SetBorder(true).
|
||||
SetTitle(" [::b]EDIT CHANNEL ").
|
||||
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||
|
||||
fields := make(map[string]*tview.InputField)
|
||||
var nameField *tview.InputField
|
||||
|
||||
if channelName == "" {
|
||||
nameField = tview.NewInputField().
|
||||
SetLabel("Channel Name").
|
||||
SetText("").
|
||||
SetFieldWidth(28)
|
||||
form.AddFormItem(nameField)
|
||||
}
|
||||
|
||||
for k, v := range existing {
|
||||
if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice {
|
||||
continue
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", v)
|
||||
field := tview.NewInputField().
|
||||
SetLabel(k).
|
||||
SetText(valStr).
|
||||
SetFieldWidth(28)
|
||||
form.AddFormItem(field)
|
||||
fields[k] = field
|
||||
}
|
||||
|
||||
form.AddButton("SAVE", func() {
|
||||
var cfg map[string]any
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
} else {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
|
||||
if _, ok := cfg["channels"]; !ok {
|
||||
cfg["channels"] = make(map[string]any)
|
||||
}
|
||||
channels, ok := cfg["channels"].(map[string]any)
|
||||
if !ok {
|
||||
channels = make(map[string]any)
|
||||
cfg["channels"] = channels
|
||||
}
|
||||
|
||||
finalName := channelName
|
||||
if channelName == "" {
|
||||
if nameField == nil || nameField.GetText() == "" {
|
||||
a.showError("Channel name is required")
|
||||
return
|
||||
}
|
||||
finalName = nameField.GetText()
|
||||
}
|
||||
|
||||
updated := make(map[string]any)
|
||||
if existing != nil {
|
||||
for k, v := range existing {
|
||||
updated[k] = v
|
||||
}
|
||||
}
|
||||
for k, field := range fields {
|
||||
val := field.GetText()
|
||||
if val == "true" {
|
||||
updated[k] = true
|
||||
} else if val == "false" {
|
||||
updated[k] = false
|
||||
} else if num, err := strconv.Atoi(val); err == nil {
|
||||
updated[k] = num
|
||||
} else {
|
||||
updated[k] = val
|
||||
}
|
||||
}
|
||||
|
||||
if channelName != "" && finalName != channelName {
|
||||
delete(channels, channelName)
|
||||
}
|
||||
channels[finalName] = updated
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
a.showError(fmt.Sprintf("Failed to save config: %v", err))
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
|
||||
a.showError(fmt.Sprintf("Failed to create config directory: %v", err))
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(configPath, data, 0o600); err != nil {
|
||||
a.showError(fmt.Sprintf("Failed to write config: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
a.hideModal("channel-edit")
|
||||
a.goBack()
|
||||
})
|
||||
|
||||
form.AddButton("CANCEL", func() {
|
||||
a.hideModal("channel-edit")
|
||||
})
|
||||
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
a.hideModal("channel-edit")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.showModal("channel-edit", centeredForm(form, 4, 20))
|
||||
}
|
||||
261
cmd/picoclaw-launcher-tui/ui/gateway.go
Normal file
261
cmd/picoclaw-launcher-tui/ui/gateway.go
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
const pidFileName = "gateway.pid"
|
||||
|
||||
type gatewayStatus struct {
|
||||
running bool
|
||||
pid int
|
||||
}
|
||||
|
||||
func getPidPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw", pidFileName)
|
||||
}
|
||||
|
||||
func isProcessRunning(pid int) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(output), strconv.Itoa(pid))
|
||||
} else if runtime.GOOS == "darwin" {
|
||||
cmd := exec.Command("ps", "aux")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(output), fmt.Sprintf(" %d ", pid))
|
||||
}
|
||||
// Linux
|
||||
_, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func getGatewayStatus() gatewayStatus {
|
||||
pidPath := getPidPath()
|
||||
data, err := os.ReadFile(pidPath)
|
||||
if err != nil {
|
||||
return gatewayStatus{running: false}
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
return gatewayStatus{running: false}
|
||||
}
|
||||
if !isProcessRunning(pid) {
|
||||
os.Remove(pidPath)
|
||||
return gatewayStatus{running: false}
|
||||
}
|
||||
return gatewayStatus{
|
||||
running: true,
|
||||
pid: pid,
|
||||
}
|
||||
}
|
||||
|
||||
func startGateway() error {
|
||||
status := getGatewayStatus()
|
||||
if status.running {
|
||||
return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
|
||||
}
|
||||
|
||||
pidPath := getPidPath()
|
||||
var cmd *exec.Cmd
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1")
|
||||
} else {
|
||||
cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 & echo $! > "+pidPath)
|
||||
}
|
||||
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd := exec.Command(
|
||||
"wmic",
|
||||
"process",
|
||||
"where",
|
||||
"name='picoclaw.exe' and commandline like '%gateway%'",
|
||||
"get",
|
||||
"processid",
|
||||
)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get gateway PID: %w", err)
|
||||
}
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines[1:] {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(line)
|
||||
if err == nil {
|
||||
os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0o600)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status = getGatewayStatus()
|
||||
if !status.running {
|
||||
return fmt.Errorf("failed to start gateway")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stopGateway() error {
|
||||
status := getGatewayStatus()
|
||||
if !status.running {
|
||||
return fmt.Errorf("gateway is not running")
|
||||
}
|
||||
|
||||
var err error
|
||||
if runtime.GOOS == "windows" {
|
||||
err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run()
|
||||
} else {
|
||||
err = exec.Command("kill", "-9", strconv.Itoa(status.pid)).Run()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 多次尝试确认进程已停止
|
||||
for i := 0; i < 5; i++ {
|
||||
if !isProcessRunning(status.pid) {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
os.Remove(getPidPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) newGatewayPage() tview.Primitive {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
flex.SetBorder(true).
|
||||
SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
flex.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
statusTV := tview.NewTextView().
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetText("Checking status...")
|
||||
statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
var updateStatus func()
|
||||
|
||||
// 使用List作为按钮,保证显示和交互正常
|
||||
buttons := tview.NewList()
|
||||
buttons.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
buttons.SetMainTextColor(tcell.ColorWhite)
|
||||
buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||
buttons.SetSelectedTextColor(tcell.ColorBlack)
|
||||
|
||||
buttons.AddItem(" [lime]START[white] ", "", 0, func() {
|
||||
if !getGatewayStatus().running {
|
||||
err := startGateway()
|
||||
if err != nil {
|
||||
a.showError(err.Error())
|
||||
}
|
||||
updateStatus()
|
||||
}
|
||||
})
|
||||
buttons.AddItem(" [red]STOP[white] ", "", 0, func() {
|
||||
if getGatewayStatus().running {
|
||||
err := stopGateway()
|
||||
if err != nil {
|
||||
a.showError(err.Error())
|
||||
}
|
||||
updateStatus()
|
||||
}
|
||||
})
|
||||
|
||||
buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
|
||||
buttonFlex.
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(buttons, 20, 1, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false)
|
||||
|
||||
flex.
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(statusTV, 3, 1, false).
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(buttonFlex, 4, 1, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false)
|
||||
|
||||
updateStatus = func() {
|
||||
status := getGatewayStatus()
|
||||
if status.running {
|
||||
statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d", status.pid))
|
||||
buttons.SetItemText(0, " [gray]START[white] ", "")
|
||||
buttons.SetItemText(1, " [red]STOP[white] ", "")
|
||||
} else {
|
||||
statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A")
|
||||
buttons.SetItemText(0, " [lime]START[white] ", "")
|
||||
buttons.SetItemText(1, " [gray]STOP[white] ", "")
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
a.tapp.QueueUpdateDraw(updateStatus)
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
originalInputCapture := flex.GetInputCapture()
|
||||
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
close(done)
|
||||
return a.goBack()
|
||||
}
|
||||
if originalInputCapture != nil {
|
||||
return originalInputCapture(event)
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.pageRefreshFns["gateway"] = updateStatus
|
||||
|
||||
return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ")
|
||||
}
|
||||
70
cmd/picoclaw-launcher-tui/ui/home.go
Normal file
70
cmd/picoclaw-launcher-tui/ui/home.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
func (a *App) newHomePage() tview.Primitive {
|
||||
list := tview.NewList()
|
||||
list.SetBorder(true).
|
||||
SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
|
||||
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
|
||||
list.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)),
|
||||
)
|
||||
list.SetHighlightFullLine(true)
|
||||
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
rebuildList := func() {
|
||||
sel := list.GetCurrentItem()
|
||||
list.Clear()
|
||||
list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() {
|
||||
a.navigateTo("schemes", a.newSchemesPage())
|
||||
})
|
||||
list.AddItem(
|
||||
"CHANNELS: Configure communication channels",
|
||||
"Manage Telegram/Discord/WeChat channels",
|
||||
'n',
|
||||
func() {
|
||||
a.navigateTo("channels", a.newChannelsPage())
|
||||
},
|
||||
)
|
||||
list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() {
|
||||
a.navigateTo("gateway", a.newGatewayPage())
|
||||
})
|
||||
list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() {
|
||||
a.tapp.Suspend(func() {
|
||||
cmd := exec.Command("picoclaw", "agent")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
_ = cmd.Run()
|
||||
})
|
||||
})
|
||||
list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() })
|
||||
if sel >= 0 && sel < list.GetItemCount() {
|
||||
list.SetCurrentItem(sel)
|
||||
}
|
||||
}
|
||||
rebuildList()
|
||||
|
||||
a.pageRefreshFns["home"] = rebuildList
|
||||
|
||||
return a.buildShell(
|
||||
"home",
|
||||
list,
|
||||
" [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ",
|
||||
)
|
||||
}
|
||||
200
cmd/picoclaw-launcher-tui/ui/models.go
Normal file
200
cmd/picoclaw-launcher-tui/ui/models.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
)
|
||||
|
||||
type modelsAPIResponse struct {
|
||||
Data []modelEntry `json:"data"`
|
||||
}
|
||||
|
||||
type modelEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive {
|
||||
table := tview.NewTable().
|
||||
SetBorders(false).
|
||||
SetSelectable(true, false).
|
||||
SetFixed(0, 0)
|
||||
table.SetBorder(true).
|
||||
SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
table.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||
)
|
||||
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
var modelIDs []string
|
||||
|
||||
status := tview.NewTextView().
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetDynamicColors(true).
|
||||
SetText("[#ffff00]FETCHING MODELS...[-]")
|
||||
status.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
flex := tview.NewFlex().
|
||||
SetDirection(tview.FlexRow).
|
||||
AddItem(status, 1, 0, false).
|
||||
AddItem(table, 0, 1, false)
|
||||
|
||||
apiKey := a.resolveKey(schemeName, userName)
|
||||
|
||||
go func() {
|
||||
var entries []modelEntry
|
||||
var err error
|
||||
if apiKey == "" {
|
||||
err = fmt.Errorf("key is required")
|
||||
} else {
|
||||
entries, err = fetchModels(baseURL, apiKey)
|
||||
}
|
||||
|
||||
a.modelCacheMu.Lock()
|
||||
if a.modelCache == nil {
|
||||
a.modelCache = make(map[string][]modelEntry)
|
||||
}
|
||||
if err == nil && len(entries) > 0 {
|
||||
a.modelCache[cacheKey(schemeName, userName)] = entries
|
||||
} else {
|
||||
a.modelCache[cacheKey(schemeName, userName)] = nil
|
||||
}
|
||||
a.modelCacheMu.Unlock()
|
||||
|
||||
a.tapp.QueueUpdateDraw(func() {
|
||||
if err != nil {
|
||||
status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error()))
|
||||
table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)"))
|
||||
a.tapp.SetFocus(table)
|
||||
return
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
status.SetText("[#ff2a2a]NO MODELS RETURNED[-]")
|
||||
table.SetCell(0, 0, tview.NewTableCell(" (no models available)"))
|
||||
a.tapp.SetFocus(table)
|
||||
return
|
||||
}
|
||||
|
||||
status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries)))
|
||||
for i, m := range entries {
|
||||
modelIDs = append(modelIDs, m.ID)
|
||||
table.SetCell(i, 0,
|
||||
tview.NewTableCell(fmt.Sprintf("%3d", i+1)).
|
||||
SetAlign(tview.AlignRight).
|
||||
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||
SetSelectable(false),
|
||||
)
|
||||
table.SetCell(i, 1,
|
||||
tview.NewTableCell(" "+m.ID).
|
||||
SetAlign(tview.AlignLeft).
|
||||
SetExpansion(1).
|
||||
SetTextColor(tcell.NewHexColor(0xe0e0e0)),
|
||||
)
|
||||
}
|
||||
a.tapp.SetFocus(table)
|
||||
})
|
||||
}()
|
||||
|
||||
table.SetSelectedFunc(func(row, _ int) {
|
||||
if row < 0 || row >= len(modelIDs) {
|
||||
return
|
||||
}
|
||||
a.cfg.Provider.Current = tuicfg.ProviderCurrent{
|
||||
Scheme: schemeName,
|
||||
User: userName,
|
||||
Model: modelIDs[row],
|
||||
}
|
||||
a.save()
|
||||
|
||||
// Trigger model selected callback if set
|
||||
if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" {
|
||||
scheme := a.cfg.Provider.SchemeByName(schemeName)
|
||||
if scheme == nil {
|
||||
a.goBack()
|
||||
return
|
||||
}
|
||||
var user tuicfg.User
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == userName {
|
||||
user = u
|
||||
break
|
||||
}
|
||||
}
|
||||
a.OnModelSelected(*scheme, user, modelIDs[row])
|
||||
}
|
||||
|
||||
a.goBack()
|
||||
})
|
||||
|
||||
return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ")
|
||||
}
|
||||
|
||||
func (a *App) resolveKey(schemeName, userName string) string {
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == userName {
|
||||
return u.Key
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
|
||||
url := strings.TrimRight(baseURL, "/") + "/models"
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
if apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
var result modelsAPIResponse
|
||||
if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 {
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
var arr []modelEntry
|
||||
if err := json.Unmarshal(body, &arr); err == nil {
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"decode response: unrecognized shape: %s",
|
||||
strings.TrimSpace(string(body[:min(len(body), 256)])),
|
||||
)
|
||||
}
|
||||
252
cmd/picoclaw-launcher-tui/ui/schemes.go
Normal file
252
cmd/picoclaw-launcher-tui/ui/schemes.go
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
)
|
||||
|
||||
func (a *App) newSchemesPage() tview.Primitive {
|
||||
table := tview.NewTable().
|
||||
SetBorders(false).
|
||||
SetSelectable(true, false)
|
||||
table.SetBorder(true).
|
||||
SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
table.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||
)
|
||||
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
rowToIdx := func(row int) int { return row / 2 }
|
||||
|
||||
selectedSchemeName := func() string {
|
||||
row, _ := table.GetSelection()
|
||||
idx := rowToIdx(row)
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
if idx >= 0 && idx < len(schemes) {
|
||||
return schemes[idx].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
rebuild := func() {
|
||||
selName := selectedSchemeName()
|
||||
table.Clear()
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
for i, s := range schemes {
|
||||
nameRow := i * 2
|
||||
detailRow := nameRow + 1
|
||||
|
||||
table.SetCell(nameRow, 0,
|
||||
tview.NewTableCell(" "+s.Name).
|
||||
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
|
||||
SetExpansion(1).
|
||||
SetSelectable(true),
|
||||
)
|
||||
|
||||
users := a.cfg.Provider.UsersForScheme(s.Name)
|
||||
n := len(users)
|
||||
m := 0
|
||||
for _, u := range users {
|
||||
if models := a.cachedModels(s.Name, u.Name); len(models) > 0 {
|
||||
m++
|
||||
}
|
||||
}
|
||||
table.SetCell(detailRow, 0,
|
||||
tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)).
|
||||
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||
SetExpansion(1).
|
||||
SetSelectable(false),
|
||||
)
|
||||
table.SetCell(detailRow, 1,
|
||||
tview.NewTableCell("[#00f0ff]"+s.Type+" ").
|
||||
SetAlign(tview.AlignRight).
|
||||
SetSelectable(false),
|
||||
)
|
||||
}
|
||||
if selName != "" {
|
||||
for i, s := range schemes {
|
||||
if s.Name == selName {
|
||||
table.Select(i*2, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if table.GetRowCount() > 0 {
|
||||
table.Select(0, 0)
|
||||
}
|
||||
}
|
||||
rebuild()
|
||||
|
||||
a.refreshModelCache(rebuild)
|
||||
a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) }
|
||||
|
||||
table.SetSelectedFunc(func(row, _ int) {
|
||||
idx := rowToIdx(row)
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
if idx < 0 || idx >= len(schemes) {
|
||||
return
|
||||
}
|
||||
name := schemes[idx].Name
|
||||
a.navigateTo("users", a.newUsersPage(name))
|
||||
})
|
||||
|
||||
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
row, _ := table.GetSelection()
|
||||
idx := rowToIdx(row)
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
switch event.Rune() {
|
||||
case 'a':
|
||||
a.showSchemeForm(nil, func(s tuicfg.Scheme) {
|
||||
a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
|
||||
a.save()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
case 'e':
|
||||
if idx < 0 || idx >= len(schemes) {
|
||||
return nil
|
||||
}
|
||||
origName := schemes[idx].Name
|
||||
orig := schemes[idx]
|
||||
a.showSchemeForm(&orig, func(s tuicfg.Scheme) {
|
||||
current := a.cfg.Provider.Schemes
|
||||
for i, sc := range current {
|
||||
if sc.Name == origName {
|
||||
a.cfg.Provider.Schemes[i] = s
|
||||
break
|
||||
}
|
||||
}
|
||||
a.save()
|
||||
a.refreshModelCache(func() {
|
||||
rebuild()
|
||||
for i, sc := range a.cfg.Provider.Schemes {
|
||||
if sc.Name == s.Name {
|
||||
table.Select(i*2, 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return nil
|
||||
case 'd':
|
||||
if idx < 0 || idx >= len(schemes) {
|
||||
return nil
|
||||
}
|
||||
name := schemes[idx].Name
|
||||
a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
|
||||
current := a.cfg.Provider.Schemes
|
||||
newSchemes := make([]tuicfg.Scheme, 0, len(current))
|
||||
for _, sc := range current {
|
||||
if sc.Name != name {
|
||||
newSchemes = append(newSchemes, sc)
|
||||
}
|
||||
}
|
||||
a.cfg.Provider.Schemes = newSchemes
|
||||
|
||||
existing := a.cfg.Provider.Users
|
||||
filtered := make([]tuicfg.User, 0, len(existing))
|
||||
for _, u := range existing {
|
||||
if u.Scheme != name {
|
||||
filtered = append(filtered, u)
|
||||
}
|
||||
}
|
||||
a.cfg.Provider.Users = filtered
|
||||
|
||||
a.save()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return a.buildShell(
|
||||
"schemes",
|
||||
table,
|
||||
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ",
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
|
||||
name := ""
|
||||
baseURL := ""
|
||||
schemeType := "openai-compatible"
|
||||
title := " ADD SCHEME "
|
||||
|
||||
if existing != nil {
|
||||
name = existing.Name
|
||||
baseURL = existing.BaseURL
|
||||
schemeType = existing.Type
|
||||
title = " EDIT SCHEME "
|
||||
}
|
||||
|
||||
typeOptions := []string{"openai-compatible", "anthropic"}
|
||||
typeIdx := 0
|
||||
for i, t := range typeOptions {
|
||||
if t == schemeType {
|
||||
typeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
form := tview.NewForm()
|
||||
|
||||
form.
|
||||
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
|
||||
AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }).
|
||||
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
|
||||
AddButton("SAVE", func() {
|
||||
if name == "" {
|
||||
a.showError("Name is required")
|
||||
return
|
||||
}
|
||||
if baseURL == "" {
|
||||
a.showError("Base URL is required")
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
for _, s := range a.cfg.Provider.Schemes {
|
||||
if s.Name == name {
|
||||
a.showError(fmt.Sprintf("Scheme name %q already exists", name))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
a.hideModal("scheme-form")
|
||||
onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType})
|
||||
}).
|
||||
AddButton("CANCEL", func() {
|
||||
a.hideModal("scheme-form")
|
||||
})
|
||||
|
||||
form.SetBorder(true).
|
||||
SetTitle(" [::b]" + title + " ").
|
||||
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
a.hideModal("scheme-form")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.showModal("scheme-form", centeredForm(form, 4, 12))
|
||||
}
|
||||
261
cmd/picoclaw-launcher-tui/ui/users.go
Normal file
261
cmd/picoclaw-launcher-tui/ui/users.go
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
)
|
||||
|
||||
func (a *App) newUsersPage(schemeName string) tview.Primitive {
|
||||
table := tview.NewTable().
|
||||
SetBorders(false).
|
||||
SetSelectable(true, false)
|
||||
table.SetBorder(true).
|
||||
SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
table.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||
)
|
||||
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
visibleUsers := func() []tuicfg.User {
|
||||
var out []tuicfg.User
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
findUserGlobalIdx := func(userName string) int {
|
||||
for i, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == userName {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
rowToVisIdx := func(row int) int { return row / 2 }
|
||||
|
||||
selectedUserName := func() string {
|
||||
row, _ := table.GetSelection()
|
||||
users := visibleUsers()
|
||||
visIdx := rowToVisIdx(row)
|
||||
if visIdx >= 0 && visIdx < len(users) {
|
||||
return users[visIdx].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
rebuild := func() {
|
||||
selName := selectedUserName()
|
||||
table.Clear()
|
||||
users := visibleUsers()
|
||||
for i, u := range users {
|
||||
nameRow := i * 2
|
||||
detailRow := nameRow + 1
|
||||
|
||||
table.SetCell(nameRow, 0,
|
||||
tview.NewTableCell(" "+u.Name).
|
||||
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
|
||||
SetExpansion(1).
|
||||
SetSelectable(true),
|
||||
)
|
||||
table.SetCell(nameRow, 1,
|
||||
tview.NewTableCell("").
|
||||
SetSelectable(false),
|
||||
)
|
||||
|
||||
models := a.cachedModels(schemeName, u.Name)
|
||||
var detailText string
|
||||
if len(models) > 0 {
|
||||
detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models))
|
||||
} else {
|
||||
detailText = " [#ff2a2a]Inactive / No Access[-]"
|
||||
}
|
||||
table.SetCell(detailRow, 0,
|
||||
tview.NewTableCell(detailText).
|
||||
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||
SetExpansion(1).
|
||||
SetSelectable(false),
|
||||
)
|
||||
table.SetCell(detailRow, 1,
|
||||
tview.NewTableCell("[#00f0ff]"+u.Type+" ").
|
||||
SetAlign(tview.AlignRight).
|
||||
SetSelectable(false),
|
||||
)
|
||||
}
|
||||
if selName != "" {
|
||||
for i, u := range users {
|
||||
if u.Name == selName {
|
||||
table.Select(i*2, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if table.GetRowCount() > 0 {
|
||||
table.Select(0, 0)
|
||||
}
|
||||
}
|
||||
rebuild()
|
||||
|
||||
a.refreshModelCache(rebuild)
|
||||
a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) }
|
||||
|
||||
table.SetSelectedFunc(func(row, _ int) {
|
||||
visIdx := rowToVisIdx(row)
|
||||
users := visibleUsers()
|
||||
if visIdx < 0 || visIdx >= len(users) {
|
||||
return
|
||||
}
|
||||
uName := users[visIdx].Name
|
||||
scheme := a.cfg.Provider.SchemeByName(schemeName)
|
||||
if scheme == nil {
|
||||
a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
|
||||
return
|
||||
}
|
||||
a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
|
||||
})
|
||||
|
||||
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
row, _ := table.GetSelection()
|
||||
visIdx := rowToVisIdx(row)
|
||||
users := visibleUsers()
|
||||
switch event.Rune() {
|
||||
case 'a':
|
||||
a.showUserForm(schemeName, nil, func(u tuicfg.User) {
|
||||
a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
|
||||
a.save()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
case 'e':
|
||||
if visIdx < 0 || visIdx >= len(users) {
|
||||
return nil
|
||||
}
|
||||
origName := users[visIdx].Name
|
||||
orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)]
|
||||
a.showUserForm(schemeName, &orig, func(u tuicfg.User) {
|
||||
cfgIdx := findUserGlobalIdx(origName)
|
||||
if cfgIdx < 0 {
|
||||
a.showError(fmt.Sprintf("User %q no longer exists", origName))
|
||||
return
|
||||
}
|
||||
a.cfg.Provider.Users[cfgIdx] = u
|
||||
a.save()
|
||||
a.refreshModelCache(func() {
|
||||
rebuild()
|
||||
for i, usr := range visibleUsers() {
|
||||
if usr.Name == u.Name {
|
||||
table.Select(i*2, 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return nil
|
||||
case 'd':
|
||||
if visIdx < 0 || visIdx >= len(users) {
|
||||
return nil
|
||||
}
|
||||
uName := users[visIdx].Name
|
||||
a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
|
||||
cfgIdx := findUserGlobalIdx(uName)
|
||||
if cfgIdx < 0 {
|
||||
return
|
||||
}
|
||||
all := a.cfg.Provider.Users
|
||||
a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...)
|
||||
a.save()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return a.buildShell(
|
||||
"users",
|
||||
table,
|
||||
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ",
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
|
||||
name := ""
|
||||
userType := "key"
|
||||
key := ""
|
||||
title := " ADD USER "
|
||||
|
||||
if existing != nil {
|
||||
name = existing.Name
|
||||
userType = existing.Type
|
||||
key = existing.Key
|
||||
title = " EDIT USER "
|
||||
}
|
||||
|
||||
typeOptions := []string{"key", "OAuth"}
|
||||
typeIdx := 0
|
||||
for i, t := range typeOptions {
|
||||
if t == userType {
|
||||
typeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
form := tview.NewForm()
|
||||
form.
|
||||
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
|
||||
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
|
||||
AddPasswordField("Key", key, 28, '*', func(text string) { key = text }).
|
||||
AddButton("SAVE", func() {
|
||||
if name == "" {
|
||||
a.showError("Name is required")
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == name {
|
||||
a.showError(fmt.Sprintf("User name %q already exists for this scheme", name))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
a.hideModal("user-form")
|
||||
onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key})
|
||||
}).
|
||||
AddButton("CANCEL", func() {
|
||||
a.hideModal("user-form")
|
||||
})
|
||||
|
||||
form.SetBorder(true).
|
||||
SetTitle(" [::b]" + title + " ").
|
||||
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
a.hideModal("user-form")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.showModal("user-form", centeredForm(form, 4, 13))
|
||||
}
|
||||
|
|
@ -1,290 +0,0 @@
|
|||
# PicoClaw Launcher
|
||||
|
||||
> [!WARNING]
|
||||
> This project is a temporary solution and will be refactored in the future to provide a complete web service. Therefore, the APIs in this directory are not stable.
|
||||
|
||||
A standalone launcher for PicoClaw, providing visual JSON editing and OAuth provider authentication management.
|
||||
|
||||
## Features
|
||||
|
||||
- 📝 **Config Editor** — Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor
|
||||
- 🤖 **Model Management** — Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation
|
||||
- 📡 **Channel Configuration** — Form-based settings for 12 channel types (Telegram, Discord, Slack, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links
|
||||
- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth)
|
||||
- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies
|
||||
- 🌍 **i18n** — Chinese/English language switching with browser auto-detection
|
||||
- 🎨 **Theme** — Light / Dark / System theme toggle with localStorage persistence
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build
|
||||
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||
|
||||
# Run with default config path (~/.picoclaw/config.json)
|
||||
./picoclaw-launcher
|
||||
|
||||
# Specify a config file
|
||||
./picoclaw-launcher ./config.json
|
||||
|
||||
# Allow LAN access
|
||||
./picoclaw-launcher -public
|
||||
```
|
||||
|
||||
Open `http://localhost:18800` in your browser.
|
||||
|
||||
## CLI Options
|
||||
|
||||
```
|
||||
Usage: picoclaw-config [options] [config.json]
|
||||
|
||||
Arguments:
|
||||
config.json Path to the configuration file (default: ~/.picoclaw/config.json)
|
||||
|
||||
Options:
|
||||
-public Listen on all interfaces (0.0.0.0), allowing access from other devices
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Base URL: `http://localhost:18800`
|
||||
|
||||
---
|
||||
|
||||
### Static Files
|
||||
|
||||
#### GET /
|
||||
|
||||
Serves the embedded frontend (`index.html`).
|
||||
|
||||
---
|
||||
|
||||
### Config API
|
||||
|
||||
#### GET /api/config
|
||||
|
||||
Reads the current configuration file.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"config": { ... },
|
||||
"path": "/Users/xiao/.picoclaw/config.json"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### PUT /api/config
|
||||
|
||||
Saves the configuration. The request body must be a complete Config JSON object.
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": { "defaults": { "model_name": "gpt-5.2" } },
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
**Error** `400 Bad Request` — Invalid JSON
|
||||
|
||||
---
|
||||
|
||||
### Auth API
|
||||
|
||||
#### GET /api/auth/status
|
||||
|
||||
Returns the authentication status of all providers and any in-progress device code login.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"provider": "openai",
|
||||
"auth_method": "oauth",
|
||||
"status": "active",
|
||||
"account_id": "user-xxx",
|
||||
"expires_at": "2026-03-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"pending_device": {
|
||||
"provider": "openai",
|
||||
"status": "pending",
|
||||
"device_url": "https://auth.openai.com/activate",
|
||||
"user_code": "ABCD-1234"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`status` values: `active` | `expired` | `needs_refresh`
|
||||
|
||||
`pending_device` is only present when a device code login is in progress.
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/auth/login
|
||||
|
||||
Initiates a provider login.
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{ "provider": "openai" }
|
||||
```
|
||||
|
||||
Supported `provider` values: `openai` | `anthropic` | `google-antigravity`
|
||||
|
||||
##### OpenAI (Device Code Flow)
|
||||
|
||||
Returns device code info. The server polls for completion in the background.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pending",
|
||||
"device_url": "https://auth.openai.com/activate",
|
||||
"user_code": "ABCD-1234",
|
||||
"message": "Open the URL and enter the code to authenticate."
|
||||
}
|
||||
```
|
||||
|
||||
The user opens `device_url` in a browser and enters `user_code`. Once authenticated, `GET /api/auth/status` will show `pending_device.status` as `success`.
|
||||
|
||||
##### Anthropic (API Token)
|
||||
|
||||
Requires a `token` field in the request:
|
||||
|
||||
```json
|
||||
{ "provider": "anthropic", "token": "sk-ant-xxx" }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "status": "success", "message": "Anthropic token saved" }
|
||||
```
|
||||
|
||||
##### Google Antigravity (Browser OAuth)
|
||||
|
||||
Returns an authorization URL for the frontend to open in a new tab:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "redirect",
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
|
||||
"message": "Open the URL to authenticate with Google."
|
||||
}
|
||||
```
|
||||
|
||||
After authentication, Google redirects to `GET /auth/callback`, which saves the credentials and redirects back to the picoclaw-config UI.
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/auth/logout
|
||||
|
||||
Logs out from a provider.
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{ "provider": "openai" }
|
||||
```
|
||||
|
||||
Omit or leave `provider` empty to log out from all providers.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /auth/callback
|
||||
|
||||
OAuth browser callback endpoint (used by Google Antigravity). Called by the OAuth provider's redirect — **not invoked directly by the frontend**.
|
||||
|
||||
**Query Parameters:**
|
||||
- `state` — OAuth state for CSRF validation
|
||||
- `code` — Authorization code
|
||||
|
||||
On success, redirects to `/#auth`.
|
||||
|
||||
|
||||
### Process API
|
||||
|
||||
#### GET /api/process/status
|
||||
|
||||
Gets the running status of the `picoclaw gateway` process.
|
||||
|
||||
**Response** `200 OK` (Running)
|
||||
|
||||
```json
|
||||
{
|
||||
"process_status": "running",
|
||||
"status": "ok",
|
||||
"uptime": "1.010814s"
|
||||
}
|
||||
```
|
||||
|
||||
**Response** `200 OK` (Stopped)
|
||||
|
||||
```json
|
||||
{
|
||||
"process_status": "stopped",
|
||||
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/process/start
|
||||
|
||||
Starts the `picoclaw gateway` process in the background.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"pid": 12345
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/process/stop
|
||||
|
||||
Stops the running `picoclaw gateway` process.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go test -v ./cmd/picoclaw-launcher/
|
||||
```
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
# PicoClaw Launcher
|
||||
|
||||
> [!WARNING]
|
||||
> 该项目属于临时解决方案,后续会重构并提供完整的 Web 服务,因此该目录下的接口并不稳定。
|
||||
|
||||
PicoClaw 的独立启动器,提供可视化 JSON 配置编辑和 OAuth Provider 认证管理。
|
||||
|
||||
## 功能
|
||||
|
||||
- 📝 **配置编辑** — 侧边栏式设置 UI,支持模型管理、通道配置表单和原始 JSON 编辑器
|
||||
- 🤖 **模型管理** — 模型卡片网格,可用性状态显示(无 API Key 时灰色),主模型选择,增删改查,必填/选填字段分离
|
||||
- 📡 **通道配置** — 12 种通道类型(Telegram、Discord、Slack、企业微信、钉钉、飞书、LINE、WhatsApp、QQ、OneBot、MaixCAM 等)的表单化配置,附带文档链接
|
||||
- 🔐 **Provider 认证** — 支持 OpenAI (Device Code)、Anthropic (API Token)、Google Antigravity (Browser OAuth) 登录
|
||||
- 🌐 **嵌入式前端** — 编译为单一二进制文件,无需额外依赖
|
||||
- 🌍 **国际化** — 中英文切换,首次访问自动检测浏览器语言
|
||||
- 🎨 **主题** — 亮色 / 暗色 / 跟随系统,偏好保存在 localStorage
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 编译
|
||||
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||
|
||||
# 运行(使用默认配置路径 ~/.picoclaw/config.json)
|
||||
./picoclaw-launcher
|
||||
|
||||
# 指定配置文件
|
||||
./picoclaw-launcher ./config.json
|
||||
|
||||
# 允许局域网访问
|
||||
./picoclaw-launcher -public
|
||||
```
|
||||
|
||||
启动后在浏览器中打开 `http://localhost:18800`。
|
||||
|
||||
## 命令行参数
|
||||
|
||||
```
|
||||
Usage: picoclaw-launcher [options] [config.json]
|
||||
|
||||
Arguments:
|
||||
config.json 配置文件路径(默认: ~/.picoclaw/config.json)
|
||||
|
||||
Options:
|
||||
-public 监听所有网络接口(0.0.0.0),允许局域网设备访问
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
Base URL: `http://localhost:18800`
|
||||
|
||||
### 静态文件
|
||||
|
||||
#### GET /
|
||||
|
||||
提供嵌入式前端页面(`index.html`)。
|
||||
|
||||
---
|
||||
|
||||
### Config API
|
||||
|
||||
#### GET /api/config
|
||||
|
||||
读取当前配置文件内容。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"config": { ... },
|
||||
"path": "/Users/xiao/.picoclaw/config.json"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### PUT /api/config
|
||||
|
||||
保存配置。请求体为完整的 Config JSON。
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": { "defaults": { "model_name": "gpt-5.2" } },
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
**Error** `400 Bad Request` — 无效 JSON
|
||||
|
||||
---
|
||||
|
||||
### Auth API
|
||||
|
||||
#### GET /api/auth/status
|
||||
|
||||
获取所有 Provider 的认证状态和进行中的 Device Code 登录信息。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"provider": "openai",
|
||||
"auth_method": "oauth",
|
||||
"status": "active",
|
||||
"account_id": "user-xxx",
|
||||
"expires_at": "2026-03-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"pending_device": {
|
||||
"provider": "openai",
|
||||
"status": "pending",
|
||||
"device_url": "https://auth.openai.com/activate",
|
||||
"user_code": "ABCD-1234"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`status` 可选值: `active` | `expired` | `needs_refresh`
|
||||
|
||||
`pending_device` 仅在有进行中的 Device Code 登录时返回。
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/auth/login
|
||||
|
||||
发起 Provider 登录。
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{ "provider": "openai" }
|
||||
```
|
||||
|
||||
支持的 `provider` 值: `openai` | `anthropic` | `google-antigravity`
|
||||
|
||||
##### OpenAI (Device Code Flow)
|
||||
|
||||
返回 Device Code 信息,后台自动轮询认证结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pending",
|
||||
"device_url": "https://auth.openai.com/activate",
|
||||
"user_code": "ABCD-1234",
|
||||
"message": "Open the URL and enter the code to authenticate."
|
||||
}
|
||||
```
|
||||
|
||||
用户在浏览器中打开 `device_url` 并输入 `user_code`。认证完成后通过 `GET /api/auth/status` 的 `pending_device.status` 变为 `success` 通知前端。
|
||||
|
||||
##### Anthropic (API Token)
|
||||
|
||||
需在请求中附带 token:
|
||||
|
||||
```json
|
||||
{ "provider": "anthropic", "token": "sk-ant-xxx" }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "status": "success", "message": "Anthropic token saved" }
|
||||
```
|
||||
|
||||
##### Google Antigravity (Browser OAuth)
|
||||
|
||||
返回授权 URL,前端打开新标签页:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "redirect",
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
|
||||
"message": "Open the URL to authenticate with Google."
|
||||
}
|
||||
```
|
||||
|
||||
认证完成后 Google 回调至 `GET /auth/callback`,自动保存凭据并重定向回 picoclaw-config 页面。
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/auth/logout
|
||||
|
||||
登出 Provider。
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{ "provider": "openai" }
|
||||
```
|
||||
|
||||
传空字符串或省略 `provider` 则登出所有 Provider。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /auth/callback
|
||||
|
||||
OAuth Browser 回调端点(Google Antigravity 专用),由 OAuth Provider 重定向调用,**非前端直接使用**。
|
||||
|
||||
**Query Parameters:**
|
||||
- `state` — OAuth state 校验
|
||||
- `code` — 授权码
|
||||
|
||||
认证成功后重定向到 `/#auth`。
|
||||
|
||||
### Process API
|
||||
|
||||
#### GET /api/process/status
|
||||
|
||||
获取 `picoclaw gateway` 进程的运行状态。
|
||||
|
||||
**Response** `200 OK` (运行中)
|
||||
|
||||
```json
|
||||
{
|
||||
"process_status": "running",
|
||||
"status": "ok",
|
||||
"uptime": "1.010814s"
|
||||
}
|
||||
```
|
||||
|
||||
**Response** `200 OK` (未运行)
|
||||
|
||||
```json
|
||||
{
|
||||
"process_status": "stopped",
|
||||
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/process/start
|
||||
|
||||
在后台启动 `picoclaw gateway` 进程。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"pid": 12345
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/process/stop
|
||||
|
||||
停止正在运行的 `picoclaw gateway` 进程。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
go test -v ./cmd/picoclaw-launcher/
|
||||
```
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// updateConfigAfterLogin updates config.json after a successful provider login.
|
||||
func updateConfigAfterLogin(configPath, provider string, cred *auth.AuthCredential) {
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
log.Printf("Warning: could not load config to update auth_method: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "openai":
|
||||
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||
found := false
|
||||
for i := range cfg.ModelList {
|
||||
if isOpenAIModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = "oauth"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
}
|
||||
cfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||
|
||||
case "anthropic":
|
||||
cfg.Providers.Anthropic.AuthMethod = "token"
|
||||
found := false
|
||||
for i := range cfg.ModelList {
|
||||
if isAnthropicModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = "token"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||
ModelName: "claude-sonnet-4.6",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
AuthMethod: "token",
|
||||
})
|
||||
}
|
||||
cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
||||
|
||||
case "google-antigravity":
|
||||
cfg.Providers.Antigravity.AuthMethod = "oauth"
|
||||
found := false
|
||||
for i := range cfg.ModelList {
|
||||
if isAntigravityModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = "oauth"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gemini-flash",
|
||||
Model: "antigravity/gemini-3-flash",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
}
|
||||
cfg.Agents.Defaults.ModelName = "gemini-flash"
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
log.Printf("Warning: could not update config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// clearAuthMethodInConfig clears auth_method for a specific provider in config.json.
|
||||
func clearAuthMethodInConfig(configPath, provider string) {
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range cfg.ModelList {
|
||||
switch provider {
|
||||
case "openai":
|
||||
if isOpenAIModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "anthropic":
|
||||
if isAnthropicModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "google-antigravity", "antigravity":
|
||||
if isAntigravityModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "openai":
|
||||
cfg.Providers.OpenAI.AuthMethod = ""
|
||||
case "anthropic":
|
||||
cfg.Providers.Anthropic.AuthMethod = ""
|
||||
case "google-antigravity", "antigravity":
|
||||
cfg.Providers.Antigravity.AuthMethod = ""
|
||||
}
|
||||
|
||||
config.SaveConfig(configPath, cfg)
|
||||
}
|
||||
|
||||
// clearAllAuthMethodsInConfig clears auth_method for all providers in config.json.
|
||||
func clearAllAuthMethodsInConfig(configPath string) {
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for i := range cfg.ModelList {
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
cfg.Providers.OpenAI.AuthMethod = ""
|
||||
cfg.Providers.Anthropic.AuthMethod = ""
|
||||
cfg.Providers.Antigravity.AuthMethod = ""
|
||||
config.SaveConfig(configPath, cfg)
|
||||
}
|
||||
|
||||
// ── Model identification helpers ─────────────────────────────────
|
||||
|
||||
func isOpenAIModel(model string) bool {
|
||||
return model == "openai" || strings.HasPrefix(model, "openai/")
|
||||
}
|
||||
|
||||
func isAnthropicModel(model string) bool {
|
||||
return model == "anthropic" || strings.HasPrefix(model, "anthropic/")
|
||||
}
|
||||
|
||||
func isAntigravityModel(model string) bool {
|
||||
return model == "antigravity" || model == "google-antigravity" ||
|
||||
strings.HasPrefix(model, "antigravity/") || strings.HasPrefix(model, "google-antigravity/")
|
||||
}
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// ── Model identification helpers ─────────────────────────────────
|
||||
|
||||
func TestIsOpenAIModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"openai", true},
|
||||
{"openai/gpt-4o", true},
|
||||
{"openai/gpt-5.2", true},
|
||||
{"anthropic", false},
|
||||
{"anthropic/claude-sonnet-4.6", false},
|
||||
{"openai-compatible", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isOpenAIModel(tt.model); got != tt.want {
|
||||
t.Errorf("isOpenAIModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAnthropicModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"anthropic", true},
|
||||
{"anthropic/claude-sonnet-4.6", true},
|
||||
{"openai", false},
|
||||
{"openai/gpt-4o", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isAnthropicModel(tt.model); got != tt.want {
|
||||
t.Errorf("isAnthropicModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAntigravityModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"antigravity", true},
|
||||
{"google-antigravity", true},
|
||||
{"antigravity/gemini-3-flash", true},
|
||||
{"google-antigravity/gemini-3-flash", true},
|
||||
{"openai", false},
|
||||
{"antigravity-custom", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isAntigravityModel(tt.model); got != tt.want {
|
||||
t.Errorf("isAntigravityModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config update helpers ────────────────────────────────────────
|
||||
|
||||
func writeTempConfigViaSave(t *testing.T, cfg *config.Config) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
if err := config.SaveConfig(path, cfg); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func loadTempConfig(t *testing.T, path string) *config.Config {
|
||||
t.Helper()
|
||||
cfg, err := config.LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestUpdateConfigAfterLogin_OpenAI_ExistingModel(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
|
||||
},
|
||||
}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||
updateConfigAfterLogin(path, "openai", cred)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
// Model-level auth_method persists through serialization
|
||||
if len(result.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model, got %d", len(result.ModelList))
|
||||
}
|
||||
if result.ModelList[0].AuthMethod != "oauth" {
|
||||
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigAfterLogin_OpenAI_NoExistingModel(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6"},
|
||||
},
|
||||
}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||
updateConfigAfterLogin(path, "openai", cred)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
if len(result.ModelList) != 2 {
|
||||
t.Fatalf("expected 2 models (original + added), got %d", len(result.ModelList))
|
||||
}
|
||||
if result.ModelList[1].Model != "openai/gpt-5.2" {
|
||||
t.Errorf("expected added model openai/gpt-5.2, got %q", result.ModelList[1].Model)
|
||||
}
|
||||
if result.Agents.Defaults.ModelName != "gpt-5.2" {
|
||||
t.Errorf("expected default model_name=gpt-5.2, got %q", result.Agents.Defaults.ModelName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigAfterLogin_Anthropic(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
cred := &auth.AuthCredential{AuthMethod: "token"}
|
||||
updateConfigAfterLogin(path, "anthropic", cred)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
// Model should be added with correct auth_method
|
||||
if len(result.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
|
||||
}
|
||||
if result.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", result.ModelList[0].Model)
|
||||
}
|
||||
if result.ModelList[0].AuthMethod != "token" {
|
||||
t.Errorf("expected model auth_method=token, got %q", result.ModelList[0].AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigAfterLogin_GoogleAntigravity(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||
updateConfigAfterLogin(path, "google-antigravity", cred)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
// Model should be added with correct auth_method
|
||||
if len(result.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
|
||||
}
|
||||
if result.ModelList[0].Model != "antigravity/gemini-3-flash" {
|
||||
t.Errorf("expected model antigravity/gemini-3-flash, got %q", result.ModelList[0].Model)
|
||||
}
|
||||
if result.ModelList[0].AuthMethod != "oauth" {
|
||||
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAuthMethodInConfig(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
|
||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||
},
|
||||
}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
clearAuthMethodInConfig(path, "openai")
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
// Openai model auth_method should be cleared
|
||||
if result.ModelList[0].AuthMethod != "" {
|
||||
t.Errorf("expected openai model auth_method cleared, got %q", result.ModelList[0].AuthMethod)
|
||||
}
|
||||
// Anthropic model should be unchanged
|
||||
if result.ModelList[1].AuthMethod != "token" {
|
||||
t.Errorf("expected anthropic model auth_method unchanged, got %q", result.ModelList[1].AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAllAuthMethodsInConfig(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
|
||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||
{ModelName: "gemini", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth"},
|
||||
},
|
||||
}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
clearAllAuthMethodsInConfig(path)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
for i, m := range result.ModelList {
|
||||
if m.AuthMethod != "" {
|
||||
t.Errorf("model[%d] auth_method not cleared, got %q", i, m.AuthMethod)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// oauthSession stores in-flight OAuth state for browser-based flows.
|
||||
type oauthSession struct {
|
||||
Provider string
|
||||
PKCE auth.PKCECodes
|
||||
State string
|
||||
RedirectURI string
|
||||
OAuthCfg auth.OAuthProviderConfig
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
// deviceCodeSession stores in-flight device code flow state.
|
||||
type deviceCodeSession struct {
|
||||
mu sync.Mutex
|
||||
Provider string
|
||||
Info *auth.DeviceCodeInfo
|
||||
OAuthCfg auth.OAuthProviderConfig
|
||||
ConfigPath string
|
||||
Status string // "pending", "success", "error"
|
||||
Error string
|
||||
Done bool
|
||||
}
|
||||
|
||||
var (
|
||||
oauthSessions = map[string]*oauthSession{} // keyed by state
|
||||
oauthSessionsMu sync.Mutex
|
||||
|
||||
activeDeviceSession *deviceCodeSession
|
||||
activeDeviceSessionMu sync.Mutex
|
||||
)
|
||||
|
||||
// handleOpenAILogin starts the OpenAI device code flow and returns device code info to the frontend.
|
||||
func handleOpenAILogin(w http.ResponseWriter, configPath string) {
|
||||
// Check if there's already a pending device code session
|
||||
activeDeviceSessionMu.Lock()
|
||||
if activeDeviceSession != nil {
|
||||
activeDeviceSession.mu.Lock()
|
||||
if !activeDeviceSession.Done {
|
||||
resp := map[string]any{
|
||||
"status": "pending",
|
||||
"device_url": activeDeviceSession.Info.VerifyURL,
|
||||
"user_code": activeDeviceSession.Info.UserCode,
|
||||
"message": "Device code flow already in progress. Enter the code in your browser.",
|
||||
}
|
||||
activeDeviceSession.mu.Unlock()
|
||||
activeDeviceSessionMu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
}
|
||||
activeDeviceSession.mu.Unlock()
|
||||
}
|
||||
activeDeviceSessionMu.Unlock()
|
||||
|
||||
// Request a device code
|
||||
oauthCfg := auth.OpenAIOAuthConfig()
|
||||
info, err := auth.RequestDeviceCode(oauthCfg)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to request device code: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
session := &deviceCodeSession{
|
||||
Provider: "openai",
|
||||
Info: info,
|
||||
OAuthCfg: oauthCfg,
|
||||
ConfigPath: configPath,
|
||||
Status: "pending",
|
||||
}
|
||||
|
||||
activeDeviceSessionMu.Lock()
|
||||
activeDeviceSession = session
|
||||
activeDeviceSessionMu.Unlock()
|
||||
|
||||
// Start background polling
|
||||
go func() {
|
||||
deadline := time.After(15 * time.Minute)
|
||||
ticker := time.NewTicker(time.Duration(info.Interval) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
session.mu.Lock()
|
||||
session.Status = "error"
|
||||
session.Error = "Authentication timed out after 15 minutes"
|
||||
session.Done = true
|
||||
session.mu.Unlock()
|
||||
return
|
||||
case <-ticker.C:
|
||||
cred, err := auth.PollDeviceCodeOnce(oauthCfg, info.DeviceAuthID, info.UserCode)
|
||||
if err != nil {
|
||||
continue // Still pending
|
||||
}
|
||||
if cred != nil {
|
||||
if saveErr := auth.SetCredential("openai", cred); saveErr != nil {
|
||||
session.mu.Lock()
|
||||
session.Status = "error"
|
||||
session.Error = saveErr.Error()
|
||||
session.Done = true
|
||||
session.mu.Unlock()
|
||||
return
|
||||
}
|
||||
updateConfigAfterLogin(configPath, "openai", cred)
|
||||
session.mu.Lock()
|
||||
session.Status = "success"
|
||||
session.Done = true
|
||||
session.mu.Unlock()
|
||||
log.Printf("OpenAI device code login successful (account: %s)", cred.AccountID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Return device code info to frontend
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "pending",
|
||||
"device_url": info.VerifyURL,
|
||||
"user_code": info.UserCode,
|
||||
"message": "Open the URL and enter the code to authenticate.",
|
||||
})
|
||||
}
|
||||
|
||||
// handleAnthropicLogin saves a pasted API token for Anthropic.
|
||||
func handleAnthropicLogin(w http.ResponseWriter, token, configPath string) {
|
||||
if token == "" {
|
||||
http.Error(w, "Token is required for Anthropic login", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cred := &auth.AuthCredential{
|
||||
AccessToken: token,
|
||||
Provider: "anthropic",
|
||||
AuthMethod: "token",
|
||||
}
|
||||
|
||||
if err := auth.SetCredential("anthropic", cred); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save credentials: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
updateConfigAfterLogin(configPath, "anthropic", cred)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "success",
|
||||
"message": "Anthropic token saved",
|
||||
})
|
||||
}
|
||||
|
||||
// handleGoogleAntigravityLogin generates a PKCE + auth URL and returns it to the frontend.
|
||||
func handleGoogleAntigravityLogin(w http.ResponseWriter, r *http.Request, configPath string) {
|
||||
oauthCfg := auth.GoogleAntigravityOAuthConfig()
|
||||
|
||||
pkce, err := auth.GeneratePKCE()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to generate PKCE: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := auth.GenerateState()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to generate state: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build redirect URI pointing to picoclaw-launcher's own callback
|
||||
scheme := "http"
|
||||
redirectURI := fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host)
|
||||
|
||||
authURL := auth.BuildAuthorizeURL(oauthCfg, pkce, state, redirectURI)
|
||||
|
||||
// Store session for callback
|
||||
oauthSessionsMu.Lock()
|
||||
oauthSessions[state] = &oauthSession{
|
||||
Provider: "google-antigravity",
|
||||
PKCE: pkce,
|
||||
State: state,
|
||||
RedirectURI: redirectURI,
|
||||
OAuthCfg: oauthCfg,
|
||||
ConfigPath: configPath,
|
||||
}
|
||||
oauthSessionsMu.Unlock()
|
||||
|
||||
// Clean up stale sessions after 10 minutes
|
||||
go func() {
|
||||
time.Sleep(10 * time.Minute)
|
||||
oauthSessionsMu.Lock()
|
||||
delete(oauthSessions, state)
|
||||
oauthSessionsMu.Unlock()
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "redirect",
|
||||
"auth_url": authURL,
|
||||
"message": "Open the URL to authenticate with Google.",
|
||||
})
|
||||
}
|
||||
|
||||
// handleOAuthCallback processes the OAuth callback from Google Antigravity.
|
||||
func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state := r.URL.Query().Get("state")
|
||||
code := r.URL.Query().Get("code")
|
||||
|
||||
oauthSessionsMu.Lock()
|
||||
session, ok := oauthSessions[state]
|
||||
if ok {
|
||||
delete(oauthSessions, state)
|
||||
}
|
||||
oauthSessionsMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
http.Error(w, "Invalid or expired OAuth state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
errMsg := r.URL.Query().Get("error")
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
|
||||
errMsg,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
cred, err := auth.ExchangeCodeForTokens(session.OAuthCfg, code, session.PKCE.CodeVerifier, session.RedirectURI)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
|
||||
err.Error(),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
cred.Provider = session.Provider
|
||||
|
||||
// Fetch user info for Google Antigravity
|
||||
if session.Provider == "google-antigravity" {
|
||||
if email, err := fetchGoogleUserEmail(cred.AccessToken); err == nil {
|
||||
cred.Email = email
|
||||
}
|
||||
if projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken); err == nil {
|
||||
cred.ProjectID = projectID
|
||||
}
|
||||
}
|
||||
|
||||
if err := auth.SetCredential(session.Provider, cred); err != nil {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<html><body><h2>Failed to save credentials</h2><p>%s</p></body></html>`, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updateConfigAfterLogin(session.ConfigPath, session.Provider, cred)
|
||||
|
||||
// Redirect back to picoclaw-launcher UI
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<html><body>
|
||||
<h2>Authentication successful!</h2>
|
||||
<p>Redirecting back to Config Editor...</p>
|
||||
<script>setTimeout(function(){ window.location.href = '/#auth'; }, 1000);</script>
|
||||
</body></html>`)
|
||||
}
|
||||
|
||||
// fetchGoogleUserEmail retrieves the user's email from Google's userinfo endpoint.
|
||||
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||
}
|
||||
|
||||
var userInfo struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userInfo.Email, nil
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLogBuffer_Basic(t *testing.T) {
|
||||
buf := NewLogBuffer(5)
|
||||
|
||||
// Empty buffer
|
||||
lines, total, runID := buf.LinesSince(0)
|
||||
assert.Nil(t, lines)
|
||||
assert.Equal(t, 0, total)
|
||||
assert.Equal(t, 0, runID)
|
||||
|
||||
// Append some lines
|
||||
buf.Append("line1")
|
||||
buf.Append("line2")
|
||||
buf.Append("line3")
|
||||
|
||||
lines, total, runID = buf.LinesSince(0)
|
||||
assert.Equal(t, []string{"line1", "line2", "line3"}, lines)
|
||||
assert.Equal(t, 3, total)
|
||||
assert.Equal(t, 0, runID)
|
||||
|
||||
// Incremental read
|
||||
lines, total, _ = buf.LinesSince(2)
|
||||
assert.Equal(t, []string{"line3"}, lines)
|
||||
assert.Equal(t, 3, total)
|
||||
|
||||
// No new lines
|
||||
lines, total, _ = buf.LinesSince(3)
|
||||
assert.Nil(t, lines)
|
||||
assert.Equal(t, 3, total)
|
||||
}
|
||||
|
||||
func TestLogBuffer_Wrap(t *testing.T) {
|
||||
buf := NewLogBuffer(3)
|
||||
|
||||
buf.Append("a")
|
||||
buf.Append("b")
|
||||
buf.Append("c")
|
||||
buf.Append("d") // evicts "a"
|
||||
buf.Append("e") // evicts "b"
|
||||
|
||||
lines, total, _ := buf.LinesSince(0)
|
||||
assert.Equal(t, []string{"c", "d", "e"}, lines)
|
||||
assert.Equal(t, 5, total)
|
||||
|
||||
// Incremental after wrap
|
||||
lines, total, _ = buf.LinesSince(3)
|
||||
assert.Equal(t, []string{"d", "e"}, lines)
|
||||
assert.Equal(t, 5, total)
|
||||
|
||||
// Offset too old (before buffer start), get all buffered
|
||||
lines, total, _ = buf.LinesSince(1)
|
||||
assert.Equal(t, []string{"c", "d", "e"}, lines)
|
||||
assert.Equal(t, 5, total)
|
||||
}
|
||||
|
||||
func TestLogBuffer_Reset(t *testing.T) {
|
||||
buf := NewLogBuffer(5)
|
||||
|
||||
buf.Append("before")
|
||||
assert.Equal(t, 0, buf.RunID())
|
||||
|
||||
buf.Reset()
|
||||
assert.Equal(t, 1, buf.RunID())
|
||||
assert.Equal(t, 0, buf.Total())
|
||||
|
||||
lines, total, runID := buf.LinesSince(0)
|
||||
assert.Nil(t, lines)
|
||||
assert.Equal(t, 0, total)
|
||||
assert.Equal(t, 1, runID)
|
||||
|
||||
buf.Append("after")
|
||||
lines, total, runID = buf.LinesSince(0)
|
||||
assert.Equal(t, []string{"after"}, lines)
|
||||
assert.Equal(t, 1, total)
|
||||
assert.Equal(t, 1, runID)
|
||||
}
|
||||
|
||||
func TestLogBuffer_Concurrent(t *testing.T) {
|
||||
buf := NewLogBuffer(100)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 10 writers
|
||||
for i := range 10 {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := range 50 {
|
||||
buf.Append(fmt.Sprintf("writer-%d-line-%d", id, j))
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 5 readers
|
||||
for range 5 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range 100 {
|
||||
buf.LinesSince(0)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, 500, buf.Total())
|
||||
}
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// gatewayLogs stores captured stdout/stderr from the gateway process launched by the launcher.
|
||||
var gatewayLogs = NewLogBuffer(200)
|
||||
|
||||
// RegisterProcessAPI registers endpoints to start, stop and check status of the picoclaw gateway.
|
||||
func RegisterProcessAPI(mux *http.ServeMux, absPath string) {
|
||||
mux.HandleFunc("GET /api/process/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleStatusGateway(w, r, absPath)
|
||||
})
|
||||
mux.HandleFunc("POST /api/process/start", handleStartGateway)
|
||||
mux.HandleFunc("POST /api/process/stop", handleStopGateway)
|
||||
}
|
||||
|
||||
func handleStartGateway(w http.ResponseWriter, r *http.Request) {
|
||||
// Locate picoclaw executable:
|
||||
// 1. Try same directory as current executable
|
||||
// 2. Fallback to just "picoclaw" (relies on $PATH)
|
||||
execPath := "picoclaw"
|
||||
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
candidate := filepath.Join(dir, "picoclaw")
|
||||
if runtime.GOOS == "windows" {
|
||||
candidate += ".exe"
|
||||
}
|
||||
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
execPath = candidate
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(execPath, "gateway")
|
||||
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
log.Printf("Failed to create stdout pipe: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
log.Printf("Failed to create stderr pipe: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear old logs and increment runID before starting
|
||||
gatewayLogs.Reset()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("Failed to start picoclaw gateway: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Read stdout and stderr into the log buffer
|
||||
go scanPipe(stdoutPipe, gatewayLogs)
|
||||
go scanPipe(stderrPipe, gatewayLogs)
|
||||
|
||||
// Wait for the process to exit in the background to avoid zombies
|
||||
go func() {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
log.Printf("Gateway process exited: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("Started picoclaw gateway (PID: %d) from %s\n", cmd.Process.Pid, execPath)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"pid": cmd.Process.Pid,
|
||||
})
|
||||
}
|
||||
|
||||
// scanPipe reads lines from r and appends them to buf. It returns when r reaches EOF.
|
||||
func scanPipe(r io.Reader, buf *LogBuffer) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // up to 1MB per line
|
||||
|
||||
for scanner.Scan() {
|
||||
buf.Append(scanner.Text())
|
||||
}
|
||||
}
|
||||
|
||||
func handleStopGateway(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
if runtime.GOOS == "windows" {
|
||||
// Kill via taskkill finding picoclaw.exe (though it might kill this config tool if it's named picoclaw-launcher.exe...? No, /IM does exact match usually, but just to be safe let's stop exactly picoclaw.exe)
|
||||
// Alternatively, we use powershell to kill processes with commandline containing 'gateway'
|
||||
psCmd := `Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -match 'picoclaw.*gateway' } | ForEach-Object { Stop-Process $_.ProcessId -Force }`
|
||||
err = exec.Command("powershell", "-Command", psCmd).Run()
|
||||
} else {
|
||||
// Linux/macOS
|
||||
err = exec.Command("pkill", "-f", "picoclaw gateway").Run()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to stop gateway (perhaps not running?): %v\n", err)
|
||||
// We still return 200 OK because pkill returns an error if no process was found
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok", // or "not_found"
|
||||
"msg": "Stop command executed, but returned error (process might not be running).",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Stopped picoclaw gateway processes.\n")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
func handleStatusGateway(w http.ResponseWriter, r *http.Request, absPath string) {
|
||||
cfg, cfgErr := config.LoadConfig(absPath)
|
||||
host := "127.0.0.1"
|
||||
port := 18790
|
||||
if cfgErr == nil && cfg != nil {
|
||||
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
|
||||
host = cfg.Gateway.Host
|
||||
}
|
||||
if cfg.Gateway.Port != 0 {
|
||||
port = cfg.Gateway.Port
|
||||
}
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
client := http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
|
||||
// Build the response data map
|
||||
data := map[string]any{}
|
||||
|
||||
if err != nil {
|
||||
data["process_status"] = "stopped"
|
||||
data["error"] = err.Error()
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
data["process_status"] = "error"
|
||||
data["status_code"] = resp.StatusCode
|
||||
} else {
|
||||
var healthData map[string]any
|
||||
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
|
||||
data["process_status"] = "error"
|
||||
data["error"] = "invalid response from gateway"
|
||||
} else {
|
||||
// Gateway is running and responded properly — merge health data
|
||||
for k, v := range healthData {
|
||||
data[k] = v
|
||||
}
|
||||
data["process_status"] = "running"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append log data from the buffer
|
||||
appendLogData(r, data)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// appendLogData reads log_offset and log_run_id query params from the request and
|
||||
// populates the response data map with incremental log lines.
|
||||
func appendLogData(r *http.Request, data map[string]any) {
|
||||
clientOffset := 0
|
||||
clientRunID := -1
|
||||
|
||||
if v := r.URL.Query().Get("log_offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
clientOffset = n
|
||||
}
|
||||
}
|
||||
|
||||
if v := r.URL.Query().Get("log_run_id"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
clientRunID = n
|
||||
}
|
||||
}
|
||||
|
||||
runID := gatewayLogs.RunID()
|
||||
|
||||
// If runID is 0 (never reset = never launched from this launcher), report no source
|
||||
if runID == 0 {
|
||||
data["logs"] = []string{}
|
||||
data["log_total"] = 0
|
||||
data["log_run_id"] = 0
|
||||
data["log_source"] = "none"
|
||||
return
|
||||
}
|
||||
|
||||
// If the client's runID doesn't match, send all buffered lines (gateway restarted)
|
||||
offset := clientOffset
|
||||
if clientRunID != runID {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
lines, total, runID := gatewayLogs.LinesSince(offset)
|
||||
if lines == nil {
|
||||
lines = []string{}
|
||||
}
|
||||
|
||||
data["logs"] = lines
|
||||
data["log_total"] = total
|
||||
data["log_run_id"] = runID
|
||||
data["log_source"] = "launcher"
|
||||
}
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
const DefaultPort = "18800"
|
||||
|
||||
// providerStatus represents the auth status of a single provider in API responses.
|
||||
type providerStatus struct {
|
||||
Provider string `json:"provider"`
|
||||
AuthMethod string `json:"auth_method"`
|
||||
Status string `json:"status"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// ── Route registration ───────────────────────────────────────────
|
||||
|
||||
func RegisterConfigAPI(mux *http.ServeMux, absPath string) {
|
||||
// GET /api/config — read config
|
||||
mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := config.LoadConfig(absPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp := map[string]any{
|
||||
"config": cfg,
|
||||
"path": absPath,
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(resp); err != nil {
|
||||
log.Printf("Failed to encode response: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/config — save config
|
||||
mux.HandleFunc("PUT /api/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var cfg config.Config
|
||||
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(absPath, &cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
}
|
||||
|
||||
func RegisterAuthAPI(mux *http.ServeMux, absPath string) {
|
||||
// GET /api/auth/status — all authenticated providers + pending login state
|
||||
mux.HandleFunc("GET /api/auth/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
store, err := auth.LoadStore()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load auth store: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result := []providerStatus{}
|
||||
for name, cred := range store.Credentials {
|
||||
status := "active"
|
||||
if cred.IsExpired() {
|
||||
status = "expired"
|
||||
} else if cred.NeedsRefresh() {
|
||||
status = "needs_refresh"
|
||||
}
|
||||
ps := providerStatus{
|
||||
Provider: name,
|
||||
AuthMethod: cred.AuthMethod,
|
||||
Status: status,
|
||||
AccountID: cred.AccountID,
|
||||
Email: cred.Email,
|
||||
ProjectID: cred.ProjectID,
|
||||
}
|
||||
if !cred.ExpiresAt.IsZero() {
|
||||
ps.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
result = append(result, ps)
|
||||
}
|
||||
|
||||
// Include pending device code state
|
||||
var pendingDevice map[string]any
|
||||
activeDeviceSessionMu.Lock()
|
||||
if activeDeviceSession != nil {
|
||||
activeDeviceSession.mu.Lock()
|
||||
pendingDevice = map[string]any{
|
||||
"provider": activeDeviceSession.Provider,
|
||||
"status": activeDeviceSession.Status,
|
||||
"device_url": activeDeviceSession.Info.VerifyURL,
|
||||
"user_code": activeDeviceSession.Info.UserCode,
|
||||
}
|
||||
if activeDeviceSession.Error != "" {
|
||||
pendingDevice["error"] = activeDeviceSession.Error
|
||||
}
|
||||
if activeDeviceSession.Done {
|
||||
activeDeviceSession.mu.Unlock()
|
||||
activeDeviceSession = nil
|
||||
} else {
|
||||
activeDeviceSession.mu.Unlock()
|
||||
}
|
||||
}
|
||||
activeDeviceSessionMu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"providers": result,
|
||||
"pending_device": pendingDevice,
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/auth/login — initiate provider login
|
||||
mux.HandleFunc("POST /api/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Provider {
|
||||
case "openai":
|
||||
handleOpenAILogin(w, absPath)
|
||||
case "anthropic":
|
||||
handleAnthropicLogin(w, req.Token, absPath)
|
||||
case "google-antigravity", "antigravity":
|
||||
handleGoogleAntigravityLogin(w, r, absPath)
|
||||
default:
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf(
|
||||
"Unsupported provider: %s (supported: openai, anthropic, google-antigravity)",
|
||||
req.Provider,
|
||||
),
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/auth/logout — logout a provider
|
||||
mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Provider == "" {
|
||||
if err := auth.DeleteAllCredentials(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
clearAllAuthMethodsInConfig(absPath)
|
||||
} else {
|
||||
if err := auth.DeleteCredential(req.Provider); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
clearAuthMethodInConfig(absPath, req.Provider)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// GET /auth/callback — OAuth browser callback for Google Antigravity
|
||||
mux.HandleFunc("GET /auth/callback", handleOAuthCallback)
|
||||
}
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// ── Config API tests ─────────────────────────────────────────────
|
||||
|
||||
func setupConfigMux(t *testing.T, cfg *config.Config) (*http.ServeMux, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
RegisterConfigAPI(mux, path)
|
||||
RegisterAuthAPI(mux, path)
|
||||
return mux, path
|
||||
}
|
||||
|
||||
func TestGetConfig(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
|
||||
},
|
||||
}
|
||||
mux, path := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Config config.Config `json:"config"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Path != path {
|
||||
t.Errorf("expected path %q, got %q", path, resp.Path)
|
||||
}
|
||||
if len(resp.Config.ModelList) != 1 {
|
||||
t.Errorf("expected 1 model, got %d", len(resp.Config.ModelList))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfig_MissingFile_ReturnsDefault(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
RegisterConfigAPI(mux, "/tmp/nonexistent-picoclaw-launcher-test/config.json")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
// LoadConfig returns a default empty config when file is missing
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for missing file (default config), got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutConfig(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, path := setupConfigMux(t, cfg)
|
||||
|
||||
newCfg := config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(newCfg)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("PUT /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
saved, err := config.LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load saved config: %v", err)
|
||||
}
|
||||
if len(saved.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model saved, got %d", len(saved.ModelList))
|
||||
}
|
||||
if saved.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", saved.ModelList[0].Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutConfig_InvalidJSON(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader("{invalid"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid JSON, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth API tests ───────────────────────────────────────────────
|
||||
|
||||
func TestAuthStatus(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/auth/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/auth/status: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Providers []providerStatus `json:"providers"`
|
||||
PendingDevice map[string]any `json:"pending_device"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
// providers should be a non-nil list (could be empty)
|
||||
if resp.Providers == nil {
|
||||
t.Error("providers should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_UnsupportedProvider(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
body := `{"provider": "unsupported"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for unsupported provider, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_AnthropicNoToken(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
body := `{"provider": "anthropic"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for anthropic without token, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_InvalidBody(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader("{bad"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid JSON body, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogout_InvalidBody(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/auth/logout", strings.NewReader("{bad"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid body, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthCallback_InvalidState(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=invalid&code=test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid state, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utility tests ────────────────────────────────────────────────
|
||||
|
||||
func TestDefaultConfigPath(t *testing.T) {
|
||||
path := DefaultConfigPath()
|
||||
if path == "" {
|
||||
t.Error("defaultConfigPath should not return empty")
|
||||
}
|
||||
if !strings.HasSuffix(path, filepath.Join(".picoclaw", "config.json")) {
|
||||
t.Errorf("expected path ending with .picoclaw/config.json, got %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLocalIP(t *testing.T) {
|
||||
// Just ensure it doesn't panic; IP may or may not be available
|
||||
ip := GetLocalIP()
|
||||
if ip != "" {
|
||||
// If returned, should look like an IP
|
||||
if !strings.Contains(ip, ".") {
|
||||
t.Errorf("getLocalIP returned non-IPv4 looking string: %q", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func DefaultConfigPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "config.json"
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw", "config.json")
|
||||
}
|
||||
|
||||
func GetLocalIP() string {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
|
||||
return ipnet.IP.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,127 +0,0 @@
|
|||
// PicoClaw Launcher - Standalone HTTP service
|
||||
//
|
||||
// Provides a web-based JSON editor for picoclaw config files,
|
||||
// with OAuth provider authentication support.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||
// ./picoclaw-launcher [config.json]
|
||||
// ./picoclaw-launcher -public config.json
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher/internal/server"
|
||||
)
|
||||
|
||||
//go:embed internal/ui/index.html
|
||||
var staticFiles embed.FS
|
||||
|
||||
func main() {
|
||||
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, "Arguments:\n")
|
||||
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Options:\n")
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
||||
fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
|
||||
fmt.Fprintf(
|
||||
os.Stderr,
|
||||
" %s -public ./config.json Allow access from other devices on the network\n",
|
||||
os.Args[0],
|
||||
)
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
configPath := server.DefaultConfigPath()
|
||||
if flag.NArg() > 0 {
|
||||
configPath = flag.Arg(0)
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to resolve config path: %v", err)
|
||||
}
|
||||
|
||||
var addr string
|
||||
if *public {
|
||||
addr = "0.0.0.0:" + server.DefaultPort
|
||||
} else {
|
||||
addr = "127.0.0.1:" + server.DefaultPort
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
server.RegisterConfigAPI(mux, absPath)
|
||||
server.RegisterAuthAPI(mux, absPath)
|
||||
server.RegisterProcessAPI(mux, absPath)
|
||||
|
||||
staticFS, err := fs.Sub(staticFiles, "internal/ui")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create sub filesystem: %v", err)
|
||||
}
|
||||
mux.Handle("/", http.FileServer(http.FS(staticFS)))
|
||||
|
||||
// Print startup banner
|
||||
fmt.Println("=============================================")
|
||||
fmt.Println(" PicoClaw Launcher")
|
||||
fmt.Println("=============================================")
|
||||
fmt.Printf(" Config file : %s\n", absPath)
|
||||
fmt.Printf(" Listen addr : %s\n\n", addr)
|
||||
fmt.Println(" Open the following URL in your browser")
|
||||
fmt.Println(" to view and edit the configuration:")
|
||||
fmt.Println()
|
||||
fmt.Printf(" >> http://localhost:%s <<\n", server.DefaultPort)
|
||||
if *public {
|
||||
if ip := server.GetLocalIP(); ip != "" {
|
||||
fmt.Printf(" >> http://%s:%s <<\n", ip, server.DefaultPort)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
// fmt.Println("=============================================")
|
||||
|
||||
go func() {
|
||||
// Wait briefly to ensure the server is ready before opening the browser
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
url := "http://localhost:" + server.DefaultPort
|
||||
if err := openBrowser(url); err != nil {
|
||||
log.Printf("Warning: Failed to auto-open browser: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// openBrowser automatically opens the given URL in the default browser.
|
||||
func openBrowser(url string) error {
|
||||
var err error
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
err = exec.Command("xdg-open", url).Start()
|
||||
case "windows":
|
||||
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
case "darwin":
|
||||
err = exec.Command("open", url).Start()
|
||||
default:
|
||||
err = fmt.Errorf("unsupported platform")
|
||||
}
|
||||
return err
|
||||
}
|
||||
64
cmd/picoclaw/dns_noresolv.go
Normal file
64
cmd/picoclaw/dns_noresolv.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// 仅在 /etc/resolv.conf 不存在时才覆盖(即 Android 环境)
|
||||
if _, err := os.Stat("/etc/resolv.conf"); err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 从环境变量获取 DNS server 列表,多个用 ; 隔开
|
||||
// 例如: PICOCLAW_DNS_SERVER="8.8.8.8:53;1.1.1.1:53;223.5.5.5:53"
|
||||
dnsEnv := os.Getenv("PICOCLAW_DNS_SERVER")
|
||||
if dnsEnv == "" {
|
||||
dnsEnv = "8.8.8.8:53;1.1.1.1:53"
|
||||
}
|
||||
|
||||
var dnsServers []string
|
||||
for _, s := range strings.Split(dnsEnv, ";") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
// 如果没有带端口号,自动补上 :53
|
||||
if _, _, err := net.SplitHostPort(s); err != nil {
|
||||
s = s + ":53"
|
||||
}
|
||||
dnsServers = append(dnsServers, s)
|
||||
}
|
||||
}
|
||||
|
||||
// 轮询索引,在多个 DNS 服务器之间轮转
|
||||
var idx uint64
|
||||
|
||||
customResolver := &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: 5 * time.Second}
|
||||
// Round-robin: 依次尝试不同的 DNS 服务器
|
||||
server := dnsServers[atomic.AddUint64(&idx, 1)%uint64(len(dnsServers))]
|
||||
return d.DialContext(ctx, "udp", server)
|
||||
},
|
||||
}
|
||||
|
||||
// 覆盖全局 DefaultResolver
|
||||
net.DefaultResolver = customResolver
|
||||
|
||||
// 覆盖 http.DefaultTransport 使用自定义 DNS 解析的 DialContext
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
Resolver: customResolver,
|
||||
}
|
||||
|
||||
if tr, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||
tr.DialContext = dialer.DialContext
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/chzyer/readline"
|
||||
"github.com/ergochat/readline"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
|
|
@ -23,16 +23,16 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
|
|||
sessionKey = "cli:default"
|
||||
}
|
||||
|
||||
if debug {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
}
|
||||
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
|
||||
if debug {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
}
|
||||
|
||||
if model != "" {
|
||||
cfg.Agents.Defaults.ModelName = model
|
||||
}
|
||||
|
|
@ -50,6 +50,7 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
|
|||
msgBus := bus.NewMessageBus()
|
||||
defer msgBus.Close()
|
||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||
defer agentLoop.Close()
|
||||
|
||||
// Print agent startup info (only for interactive mode)
|
||||
startupInfo := agentLoop.GetStartupInfo()
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ func NewAuthCommand() *cobra.Command {
|
|||
newLogoutCommand(),
|
||||
newStatusCommand(),
|
||||
newModelsCommand(),
|
||||
newWeixinCommand(),
|
||||
newWeComCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ func TestNewAuthCommand(t *testing.T) {
|
|||
"logout",
|
||||
"status",
|
||||
"models",
|
||||
"weixin",
|
||||
"wecom",
|
||||
}
|
||||
|
||||
subcommands := cmd.Commands()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -15,14 +16,17 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
||||
const (
|
||||
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
||||
defaultAnthropicModel = "claude-sonnet-4.6"
|
||||
)
|
||||
|
||||
func authLoginCmd(provider string, useDeviceCode bool) error {
|
||||
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {
|
||||
switch provider {
|
||||
case "openai":
|
||||
return authLoginOpenAI(useDeviceCode)
|
||||
case "anthropic":
|
||||
return authLoginPasteToken(provider)
|
||||
return authLoginAnthropic(useOauth)
|
||||
case "google-antigravity", "antigravity":
|
||||
return authLoginGoogleAntigravity()
|
||||
default:
|
||||
|
|
@ -52,9 +56,6 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
|||
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
// Update Providers (legacy format)
|
||||
appCfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||
|
||||
// Update or add openai in ModelList
|
||||
foundOpenAI := false
|
||||
for i := range appCfg.ModelList {
|
||||
|
|
@ -67,15 +68,15 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
|||
|
||||
// If no openai in ModelList, add it
|
||||
if !foundOpenAI {
|
||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
}
|
||||
|
||||
// Update default model to use OpenAI
|
||||
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||
appCfg.Agents.Defaults.ModelName = "gpt-5.4"
|
||||
|
||||
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||
return fmt.Errorf("could not update config: %w", err)
|
||||
|
|
@ -86,7 +87,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
|||
if cred.AccountID != "" {
|
||||
fmt.Printf("Account: %s\n", cred.AccountID)
|
||||
}
|
||||
fmt.Println("Default model set to: gpt-5.2")
|
||||
fmt.Println("Default model set to: gpt-5.4")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -126,9 +127,6 @@ func authLoginGoogleAntigravity() error {
|
|||
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
// Update Providers (legacy format, for backward compatibility)
|
||||
appCfg.Providers.Antigravity.AuthMethod = "oauth"
|
||||
|
||||
// Update or add antigravity in ModelList
|
||||
foundAntigravity := false
|
||||
for i := range appCfg.ModelList {
|
||||
|
|
@ -141,7 +139,7 @@ func authLoginGoogleAntigravity() error {
|
|||
|
||||
// If no antigravity in ModelList, add it
|
||||
if !foundAntigravity {
|
||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||
ModelName: "gemini-flash",
|
||||
Model: "antigravity/gemini-3-flash",
|
||||
AuthMethod: "oauth",
|
||||
|
|
@ -163,6 +161,79 @@ func authLoginGoogleAntigravity() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func authLoginAnthropic(useOauth bool) error {
|
||||
if useOauth {
|
||||
return authLoginAnthropicSetupToken()
|
||||
}
|
||||
|
||||
fmt.Println("Anthropic login method:")
|
||||
fmt.Println(" 1) Setup token (from `claude setup-token`) (Recommended)")
|
||||
fmt.Println(" 2) API key (from console.anthropic.com)")
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
for {
|
||||
fmt.Print("Choose [1]: ")
|
||||
choice := "1"
|
||||
if scanner.Scan() {
|
||||
text := strings.TrimSpace(scanner.Text())
|
||||
if text != "" {
|
||||
choice = text
|
||||
}
|
||||
}
|
||||
|
||||
switch choice {
|
||||
case "1":
|
||||
return authLoginAnthropicSetupToken()
|
||||
case "2":
|
||||
return authLoginPasteToken("anthropic")
|
||||
default:
|
||||
fmt.Printf("Invalid choice: %s. Please enter 1 or 2.\n", choice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func authLoginAnthropicSetupToken() error {
|
||||
cred, err := auth.LoginSetupToken(os.Stdin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
if err = auth.SetCredential("anthropic", cred); err != nil {
|
||||
return fmt.Errorf("failed to save credentials: %w", err)
|
||||
}
|
||||
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||
ModelName: defaultAnthropicModel,
|
||||
Model: "anthropic/" + defaultAnthropicModel,
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
// Only set default model if user has no default configured yet
|
||||
if appCfg.Agents.Defaults.GetModelName() == "" {
|
||||
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
|
||||
}
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||
return fmt.Errorf("could not update config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Setup token saved for Anthropic!")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||
if err != nil {
|
||||
|
|
@ -177,7 +248,10 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
|
|||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading userinfo response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||
}
|
||||
|
|
@ -205,7 +279,6 @@ func authLoginPasteToken(provider string) error {
|
|||
if err == nil {
|
||||
switch provider {
|
||||
case "anthropic":
|
||||
appCfg.Providers.Anthropic.AuthMethod = "token"
|
||||
// Update ModelList
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
|
|
@ -216,16 +289,14 @@ func authLoginPasteToken(provider string) error {
|
|||
}
|
||||
}
|
||||
if !found {
|
||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||
ModelName: "claude-sonnet-4.6",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||
ModelName: defaultAnthropicModel,
|
||||
Model: "anthropic/" + defaultAnthropicModel,
|
||||
AuthMethod: "token",
|
||||
})
|
||||
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
|
||||
}
|
||||
// Update default model
|
||||
appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
||||
case "openai":
|
||||
appCfg.Providers.OpenAI.AuthMethod = "token"
|
||||
// Update ModelList
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
|
|
@ -236,14 +307,14 @@ func authLoginPasteToken(provider string) error {
|
|||
}
|
||||
}
|
||||
if !found {
|
||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
AuthMethod: "token",
|
||||
})
|
||||
}
|
||||
// Update default model
|
||||
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||
appCfg.Agents.Defaults.ModelName = "gpt-5.4"
|
||||
}
|
||||
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||
return fmt.Errorf("could not update config: %w", err)
|
||||
|
|
@ -284,15 +355,6 @@ func authLogoutCmd(provider string) error {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Clear AuthMethod in Providers (legacy)
|
||||
switch provider {
|
||||
case "openai":
|
||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
||||
case "anthropic":
|
||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
||||
case "google-antigravity", "antigravity":
|
||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
||||
}
|
||||
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
||||
}
|
||||
|
||||
|
|
@ -311,10 +373,6 @@ func authLogoutCmd(provider string) error {
|
|||
for i := range appCfg.ModelList {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
// Clear all AuthMethods in Providers (legacy)
|
||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
||||
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
||||
}
|
||||
|
||||
|
|
@ -360,6 +418,16 @@ func authStatusCmd() error {
|
|||
if !cred.ExpiresAt.IsZero() {
|
||||
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
|
||||
if provider == "anthropic" && cred.AuthMethod == "oauth" {
|
||||
usage, err := auth.FetchAnthropicUsage(cred.AccessToken)
|
||||
if err != nil {
|
||||
fmt.Printf(" Usage: unavailable (%v)\n", err)
|
||||
} else {
|
||||
fmt.Printf(" Usage (5h): %.1f%%\n", usage.FiveHourUtilization*100)
|
||||
fmt.Printf(" Usage (7d): %.1f%%\n", usage.SevenDayUtilization*100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ func newLoginCommand() *cobra.Command {
|
|||
var (
|
||||
provider string
|
||||
useDeviceCode bool
|
||||
useOauth bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
|
|
@ -13,12 +14,16 @@ func newLoginCommand() *cobra.Command {
|
|||
Short: "Login via OAuth or paste token",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return authLoginCmd(provider, useDeviceCode)
|
||||
return authLoginCmd(provider, useDeviceCode, useOauth)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
|
||||
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
||||
cmd.Flags().BoolVar(
|
||||
&useOauth, "setup-token", false,
|
||||
"Use setup-token flow for Anthropic (from `claude setup-token`)",
|
||||
)
|
||||
_ = cmd.MarkFlagRequired("provider")
|
||||
|
||||
return cmd
|
||||
|
|
|
|||
407
cmd/picoclaw/internal/auth/wecom.go
Normal file
407
cmd/picoclaw/internal/auth/wecom.go
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
wecomQRSourceID = "picoclaw"
|
||||
wecomQRGenerateEndpoint = "https://work.weixin.qq.com/ai/qc/generate"
|
||||
wecomQRQueryEndpoint = "https://work.weixin.qq.com/ai/qc/query_result"
|
||||
wecomQRPageEndpoint = "https://work.weixin.qq.com/ai/qc/gen"
|
||||
wecomQRHTTPTimeout = 15 * time.Second
|
||||
wecomQRPollInterval = 3 * time.Second
|
||||
wecomQRPollTimeout = 5 * time.Minute
|
||||
wecomDefaultWebSocketURL = "wss://openws.work.weixin.qq.com"
|
||||
)
|
||||
|
||||
type wecomQRScanner func(context.Context, wecomQRFlowOptions) (wecomQRBotInfo, error)
|
||||
|
||||
type wecomQRFlowOptions struct {
|
||||
HTTPClient *http.Client
|
||||
GenerateURL string
|
||||
QueryURL string
|
||||
QRCodePageURL string
|
||||
SourceID string
|
||||
PollInterval time.Duration
|
||||
PollTimeout time.Duration
|
||||
Writer io.Writer
|
||||
}
|
||||
|
||||
type wecomQRBotInfo struct {
|
||||
BotID string
|
||||
Secret string
|
||||
}
|
||||
|
||||
type wecomQRSession struct {
|
||||
SCode string
|
||||
AuthURL string
|
||||
}
|
||||
|
||||
type wecomQRGenerateResponse struct {
|
||||
ErrCode int `json:"errcode,omitempty"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
Data struct {
|
||||
SCode string `json:"scode"`
|
||||
AuthURL string `json:"auth_url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type wecomQRQueryResponse struct {
|
||||
ErrCode int `json:"errcode,omitempty"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
BotInfo struct {
|
||||
BotID string `json:"botid"`
|
||||
Secret string `json:"secret"`
|
||||
} `json:"bot_info"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func newWeComCommand() *cobra.Command {
|
||||
var timeout time.Duration
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "wecom",
|
||||
Short: "Scan a WeCom QR code and configure channels.wecom",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return authWeComCmd(timeout)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().DurationVar(&timeout, "timeout", wecomQRPollTimeout, "How long to wait for QR confirmation")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func authWeComCmd(timeout time.Duration) error {
|
||||
return authWeComCmdWithScanner(context.Background(), os.Stdout, timeout, scanWeComQRCodeInteractive)
|
||||
}
|
||||
|
||||
func authWeComCmdWithScanner(
|
||||
ctx context.Context,
|
||||
writer io.Writer,
|
||||
timeout time.Duration,
|
||||
scanner wecomQRScanner,
|
||||
) error {
|
||||
if scanner == nil {
|
||||
return fmt.Errorf("wecom QR scanner is nil")
|
||||
}
|
||||
if writer == nil {
|
||||
writer = os.Stdout
|
||||
}
|
||||
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
opts := defaultWeComQRFlowOptions(timeout)
|
||||
opts.Writer = writer
|
||||
|
||||
botInfo, err := scanner(ctx, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applyWeComAuthResult(cfg, botInfo)
|
||||
|
||||
if saveErr := config.SaveConfig(internal.GetConfigPath(), cfg); saveErr != nil {
|
||||
return fmt.Errorf("failed to save config: %w", saveErr)
|
||||
}
|
||||
|
||||
fmt.Fprintln(writer)
|
||||
fmt.Fprintln(writer, "WeCom connected.")
|
||||
fmt.Fprintf(writer, "Bot ID: %s\n", botInfo.BotID)
|
||||
fmt.Fprintf(writer, "Config: %s\n", internal.GetConfigPath())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultWeComQRFlowOptions(timeout time.Duration) wecomQRFlowOptions {
|
||||
if timeout <= 0 {
|
||||
timeout = wecomQRPollTimeout
|
||||
}
|
||||
|
||||
return wecomQRFlowOptions{
|
||||
HTTPClient: &http.Client{Timeout: wecomQRHTTPTimeout},
|
||||
GenerateURL: wecomQRGenerateEndpoint,
|
||||
QueryURL: wecomQRQueryEndpoint,
|
||||
QRCodePageURL: wecomQRPageEndpoint,
|
||||
SourceID: wecomQRSourceID,
|
||||
PollInterval: wecomQRPollInterval,
|
||||
PollTimeout: timeout,
|
||||
Writer: os.Stdout,
|
||||
}
|
||||
}
|
||||
|
||||
func applyWeComAuthResult(cfg *config.Config, botInfo wecomQRBotInfo) {
|
||||
cfg.Channels.WeCom.Enabled = true
|
||||
cfg.Channels.WeCom.BotID = botInfo.BotID
|
||||
cfg.Channels.WeCom.SetSecret(botInfo.Secret)
|
||||
if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" {
|
||||
cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL
|
||||
}
|
||||
}
|
||||
|
||||
func scanWeComQRCodeInteractive(ctx context.Context, opts wecomQRFlowOptions) (wecomQRBotInfo, error) {
|
||||
opts = normalizeWeComQRFlowOptions(opts)
|
||||
|
||||
fmt.Fprintln(opts.Writer, "Requesting WeCom QR code...")
|
||||
|
||||
session, err := fetchWeComQRCode(ctx, opts)
|
||||
if err != nil {
|
||||
return wecomQRBotInfo{}, err
|
||||
}
|
||||
|
||||
fmt.Fprintln(opts.Writer)
|
||||
fmt.Fprintln(opts.Writer, "=======================================================")
|
||||
fmt.Fprintln(opts.Writer, "Please scan the following QR code with WeCom:")
|
||||
fmt.Fprintln(opts.Writer, "=======================================================")
|
||||
fmt.Fprintln(opts.Writer)
|
||||
|
||||
qrterminal.GenerateWithConfig(session.AuthURL, qrterminal.Config{
|
||||
Level: qrterminal.L,
|
||||
Writer: opts.Writer,
|
||||
HalfBlocks: true,
|
||||
})
|
||||
|
||||
pageURL, err := buildWeComQRCodePageURL(opts.QRCodePageURL, opts.SourceID, session.SCode)
|
||||
if err != nil {
|
||||
return wecomQRBotInfo{}, err
|
||||
}
|
||||
|
||||
fmt.Fprintln(opts.Writer)
|
||||
fmt.Fprintf(opts.Writer, "QR Code Link: %s\n", pageURL)
|
||||
fmt.Fprintln(opts.Writer)
|
||||
fmt.Fprintln(opts.Writer, "Waiting for scan...")
|
||||
|
||||
return pollWeComQRCodeResult(ctx, opts, session.SCode)
|
||||
}
|
||||
|
||||
func normalizeWeComQRFlowOptions(opts wecomQRFlowOptions) wecomQRFlowOptions {
|
||||
if opts.HTTPClient == nil {
|
||||
opts.HTTPClient = &http.Client{Timeout: wecomQRHTTPTimeout}
|
||||
}
|
||||
if strings.TrimSpace(opts.GenerateURL) == "" {
|
||||
opts.GenerateURL = wecomQRGenerateEndpoint
|
||||
}
|
||||
if strings.TrimSpace(opts.QueryURL) == "" {
|
||||
opts.QueryURL = wecomQRQueryEndpoint
|
||||
}
|
||||
if strings.TrimSpace(opts.QRCodePageURL) == "" {
|
||||
opts.QRCodePageURL = wecomQRPageEndpoint
|
||||
}
|
||||
if strings.TrimSpace(opts.SourceID) == "" {
|
||||
opts.SourceID = wecomQRSourceID
|
||||
}
|
||||
if opts.PollInterval <= 0 {
|
||||
opts.PollInterval = wecomQRPollInterval
|
||||
}
|
||||
if opts.PollTimeout <= 0 {
|
||||
opts.PollTimeout = wecomQRPollTimeout
|
||||
}
|
||||
if opts.Writer == nil {
|
||||
opts.Writer = os.Stdout
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func fetchWeComQRCode(ctx context.Context, opts wecomQRFlowOptions) (wecomQRSession, error) {
|
||||
generateURL, err := buildWeComQRGenerateURL(opts.GenerateURL, opts.SourceID, wecomPlatformCode())
|
||||
if err != nil {
|
||||
return wecomQRSession{}, err
|
||||
}
|
||||
|
||||
var resp wecomQRGenerateResponse
|
||||
if err := doWeComJSONGet(ctx, opts.HTTPClient, generateURL, &resp); err != nil {
|
||||
return wecomQRSession{}, fmt.Errorf("failed to get WeCom QR code: %w", err)
|
||||
}
|
||||
if resp.ErrCode != 0 {
|
||||
return wecomQRSession{}, fmt.Errorf(
|
||||
"failed to get WeCom QR code: errcode=%d errmsg=%s",
|
||||
resp.ErrCode,
|
||||
resp.ErrMsg,
|
||||
)
|
||||
}
|
||||
if resp.Data.SCode == "" || resp.Data.AuthURL == "" {
|
||||
return wecomQRSession{}, fmt.Errorf("failed to get WeCom QR code: response missing scode or auth_url")
|
||||
}
|
||||
|
||||
return wecomQRSession{
|
||||
SCode: resp.Data.SCode,
|
||||
AuthURL: resp.Data.AuthURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func pollWeComQRCodeResult(ctx context.Context, opts wecomQRFlowOptions, scode string) (wecomQRBotInfo, error) {
|
||||
if strings.TrimSpace(scode) == "" {
|
||||
return wecomQRBotInfo{}, fmt.Errorf("missing WeCom QR scode")
|
||||
}
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, opts.PollTimeout)
|
||||
defer cancel()
|
||||
|
||||
var scannedPrinted bool
|
||||
|
||||
for {
|
||||
status, err := queryWeComQRCodeStatus(timeoutCtx, opts, scode)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
|
||||
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan timed out after %s", opts.PollTimeout)
|
||||
}
|
||||
return wecomQRBotInfo{}, err
|
||||
}
|
||||
|
||||
switch strings.ToLower(status.Data.Status) {
|
||||
case "success":
|
||||
if status.Data.BotInfo.BotID == "" || status.Data.BotInfo.Secret == "" {
|
||||
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan succeeded but bot credentials are missing")
|
||||
}
|
||||
return wecomQRBotInfo{
|
||||
BotID: status.Data.BotInfo.BotID,
|
||||
Secret: status.Data.BotInfo.Secret,
|
||||
}, nil
|
||||
case "expired":
|
||||
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR code expired, please retry")
|
||||
case "scaned", "scanned":
|
||||
if !scannedPrinted {
|
||||
fmt.Fprintln(opts.Writer, "QR code scanned. Confirm the login in WeCom.")
|
||||
scannedPrinted = true
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-timeoutCtx.Done():
|
||||
if errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
|
||||
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan timed out after %s", opts.PollTimeout)
|
||||
}
|
||||
return wecomQRBotInfo{}, timeoutCtx.Err()
|
||||
case <-time.After(opts.PollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func queryWeComQRCodeStatus(ctx context.Context, opts wecomQRFlowOptions, scode string) (wecomQRQueryResponse, error) {
|
||||
queryURL, err := buildWeComQRQueryURL(opts.QueryURL, scode)
|
||||
if err != nil {
|
||||
return wecomQRQueryResponse{}, err
|
||||
}
|
||||
|
||||
var resp wecomQRQueryResponse
|
||||
if err := doWeComJSONGet(ctx, opts.HTTPClient, queryURL, &resp); err != nil {
|
||||
return wecomQRQueryResponse{}, fmt.Errorf("failed to query WeCom QR result: %w", err)
|
||||
}
|
||||
if resp.ErrCode != 0 {
|
||||
return wecomQRQueryResponse{}, fmt.Errorf(
|
||||
"failed to query WeCom QR result: errcode=%d errmsg=%s",
|
||||
resp.ErrCode,
|
||||
resp.ErrMsg,
|
||||
)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func buildWeComQRGenerateURL(baseURL, sourceID string, platformCode int) (string, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid WeCom QR generate URL: %w", err)
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
query.Set("source", sourceID)
|
||||
query.Set("sourceID", sourceID)
|
||||
query.Set("plat", strconv.Itoa(platformCode))
|
||||
u.RawQuery = query.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func buildWeComQRQueryURL(baseURL, scode string) (string, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid WeCom QR query URL: %w", err)
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
query.Set("scode", scode)
|
||||
u.RawQuery = query.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func buildWeComQRCodePageURL(baseURL, sourceID, scode string) (string, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid WeCom QR page URL: %w", err)
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
query.Set("source", sourceID)
|
||||
query.Set("sourceID", sourceID)
|
||||
query.Set("scode", scode)
|
||||
u.RawQuery = query.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func doWeComJSONGet(ctx context.Context, client *http.Client, targetURL string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("unexpected status %s", resp.Status)
|
||||
}
|
||||
return fmt.Errorf("unexpected status %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("decode JSON response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func wecomPlatformCode() int {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return 1
|
||||
case "windows":
|
||||
return 2
|
||||
case "linux":
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
157
cmd/picoclaw/internal/auth/wecom_test.go
Normal file
157
cmd/picoclaw/internal/auth/wecom_test.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestNewWeComCommand(t *testing.T) {
|
||||
cmd := newWeComCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
assert.Equal(t, "wecom", cmd.Use)
|
||||
assert.Equal(t, "Scan a WeCom QR code and configure channels.wecom", cmd.Short)
|
||||
assert.NotNil(t, cmd.Flags().Lookup("timeout"))
|
||||
}
|
||||
|
||||
func TestBuildWeComQRGenerateURL(t *testing.T) {
|
||||
rawURL, err := buildWeComQRGenerateURL("https://example.com/ai/qc/generate", wecomQRSourceID, 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("source"))
|
||||
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("sourceID"))
|
||||
assert.Equal(t, "3", parsed.Query().Get("plat"))
|
||||
}
|
||||
|
||||
func TestBuildWeComQRCodePageURL(t *testing.T) {
|
||||
rawURL, err := buildWeComQRCodePageURL("https://example.com/ai/qc/gen", wecomQRSourceID, "scode-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("source"))
|
||||
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("sourceID"))
|
||||
assert.Equal(t, "scode-1", parsed.Query().Get("scode"))
|
||||
}
|
||||
|
||||
func TestFetchWeComQRCode(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/generate", r.URL.Path)
|
||||
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("source"))
|
||||
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("sourceID"))
|
||||
assert.Equal(t, strconv.Itoa(wecomPlatformCode()), r.URL.Query().Get("plat"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
|
||||
HTTPClient: server.Client(),
|
||||
GenerateURL: server.URL + "/generate",
|
||||
Writer: bytes.NewBuffer(nil),
|
||||
})
|
||||
|
||||
session, err := fetchWeComQRCode(context.Background(), opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "scode-1", session.SCode)
|
||||
assert.Equal(t, "https://example.com/qr", session.AuthURL)
|
||||
}
|
||||
|
||||
func TestPollWeComQRCodeResult(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
call := calls.Add(1)
|
||||
assert.Equal(t, "/query", r.URL.Path)
|
||||
assert.Equal(t, "scode-1", r.URL.Query().Get("scode"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch call {
|
||||
case 1:
|
||||
_, _ = w.Write([]byte(`{"data":{"status":"wait"}}`))
|
||||
case 2:
|
||||
_, _ = w.Write([]byte(`{"data":{"status":"scaned"}}`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var output bytes.Buffer
|
||||
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
|
||||
HTTPClient: server.Client(),
|
||||
QueryURL: server.URL + "/query",
|
||||
PollInterval: time.Millisecond,
|
||||
PollTimeout: time.Second,
|
||||
Writer: &output,
|
||||
})
|
||||
|
||||
botInfo, err := pollWeComQRCodeResult(context.Background(), opts, "scode-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "bot-1", botInfo.BotID)
|
||||
assert.Equal(t, "secret-1", botInfo.Secret)
|
||||
assert.Contains(t, output.String(), "QR code scanned. Confirm the login in WeCom.")
|
||||
}
|
||||
|
||||
func TestApplyWeComAuthResult(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Channels.WeCom.WebSocketURL = ""
|
||||
|
||||
applyWeComAuthResult(cfg, wecomQRBotInfo{
|
||||
BotID: "bot-1",
|
||||
Secret: "secret-1",
|
||||
})
|
||||
|
||||
assert.True(t, cfg.Channels.WeCom.Enabled)
|
||||
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
|
||||
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret())
|
||||
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
|
||||
}
|
||||
|
||||
func TestAuthWeComCmdWithScanner(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
t.Setenv(config.EnvHome, tmpDir)
|
||||
t.Setenv(config.EnvConfig, configPath)
|
||||
|
||||
var output bytes.Buffer
|
||||
err := authWeComCmdWithScanner(
|
||||
context.Background(),
|
||||
&output,
|
||||
time.Second,
|
||||
func(_ context.Context, opts wecomQRFlowOptions) (wecomQRBotInfo, error) {
|
||||
assert.Equal(t, wecomQRSourceID, opts.SourceID)
|
||||
return wecomQRBotInfo{
|
||||
BotID: "bot-1",
|
||||
Secret: "secret-1",
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := config.LoadConfig(internal.GetConfigPath())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, cfg.Channels.WeCom.Enabled)
|
||||
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
|
||||
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret())
|
||||
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
|
||||
assert.Contains(t, output.String(), "WeCom connected.")
|
||||
}
|
||||
124
cmd/picoclaw/internal/auth/weixin.go
Normal file
124
cmd/picoclaw/internal/auth/weixin.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/channels/weixin"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func newWeixinCommand() *cobra.Command {
|
||||
var baseURL string
|
||||
var proxy string
|
||||
var timeout int
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "weixin",
|
||||
Short: "Connect a WeChat personal account via QR code",
|
||||
Long: `Start the interactive Weixin (WeChat personal) QR code login flow.
|
||||
|
||||
A QR code is displayed in the terminal. Scan it with the WeChat mobile app
|
||||
to authorize your account. On success, the bot token is saved to the picoclaw
|
||||
config so you can start the gateway immediately.
|
||||
|
||||
Example:
|
||||
picoclaw auth weixin`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runWeixinOnboard(baseURL, proxy, time.Duration(timeout)*time.Second)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&baseURL, "base-url", "https://ilinkai.weixin.qq.com/", "iLink API base URL")
|
||||
cmd.Flags().StringVar(&proxy, "proxy", "", "HTTP proxy URL (e.g. http://localhost:7890)")
|
||||
cmd.Flags().IntVar(&timeout, "timeout", 300, "Login timeout in seconds")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runWeixinOnboard(baseURL, proxy string, timeout time.Duration) error {
|
||||
fmt.Println("Starting Weixin (WeChat personal) login...")
|
||||
fmt.Println()
|
||||
|
||||
botToken, userID, accountID, returnedBaseURL, err := weixin.PerformLoginInteractive(
|
||||
context.Background(),
|
||||
weixin.AuthFlowOpts{
|
||||
BaseURL: baseURL,
|
||||
Timeout: timeout,
|
||||
Proxy: proxy,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("✅ Login successful!")
|
||||
fmt.Printf(" Account ID : %s\n", accountID)
|
||||
if userID != "" {
|
||||
fmt.Printf(" User ID : %s\n", userID)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Prefer the server-returned base URL (may be region-specific)
|
||||
effectiveBaseURL := returnedBaseURL
|
||||
if effectiveBaseURL == "" {
|
||||
effectiveBaseURL = baseURL
|
||||
}
|
||||
|
||||
if err := saveWeixinConfig(botToken, effectiveBaseURL, proxy); err != nil {
|
||||
fmt.Printf("⚠️ Could not auto-save to config: %v\n", err)
|
||||
printManualWeixinConfig(botToken, effectiveBaseURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("✓ Config updated. Start the gateway with:")
|
||||
fmt.Println()
|
||||
fmt.Println(" picoclaw gateway")
|
||||
fmt.Println()
|
||||
fmt.Println("To restrict which WeChat users can send messages, add their user IDs")
|
||||
fmt.Println("to channels.weixin.allow_from in your config.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveWeixinConfig patches channels.weixin in the config and saves it.
|
||||
func saveWeixinConfig(token, baseURL, proxy string) error {
|
||||
cfgPath := internal.GetConfigPath()
|
||||
|
||||
cfg, err := config.LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
cfg.Channels.Weixin.Enabled = true
|
||||
cfg.Channels.Weixin.SetToken(token)
|
||||
const defaultBase = "https://ilinkai.weixin.qq.com/"
|
||||
if baseURL != "" && baseURL != defaultBase {
|
||||
cfg.Channels.Weixin.BaseURL = baseURL
|
||||
}
|
||||
if proxy != "" {
|
||||
cfg.Channels.Weixin.Proxy = proxy
|
||||
}
|
||||
|
||||
return config.SaveConfig(cfgPath, cfg)
|
||||
}
|
||||
|
||||
func printManualWeixinConfig(token, baseURL string) {
|
||||
fmt.Println()
|
||||
fmt.Println("Add the following to the channels section of your picoclaw config:")
|
||||
fmt.Println()
|
||||
fmt.Println(` "weixin": {`)
|
||||
fmt.Println(` "enabled": true,`)
|
||||
fmt.Printf(" \"token\": %q,\n", token)
|
||||
const defaultBase = "https://ilinkai.weixin.qq.com/"
|
||||
if baseURL != "" && baseURL != defaultBase {
|
||||
fmt.Printf(" \"base_url\": %q,\n", baseURL)
|
||||
}
|
||||
fmt.Println(` "allow_from": []`)
|
||||
fmt.Println(` }`)
|
||||
}
|
||||
|
|
@ -1,23 +1,52 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/gateway"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
func NewGatewayCommand() *cobra.Command {
|
||||
var debug bool
|
||||
var noTruncate bool
|
||||
var allowEmpty bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "gateway",
|
||||
Aliases: []string{"g"},
|
||||
Short: "Start picoclaw gateway",
|
||||
Args: cobra.NoArgs,
|
||||
PreRunE: func(_ *cobra.Command, _ []string) error {
|
||||
if noTruncate && !debug {
|
||||
return fmt.Errorf("the --no-truncate option can only be used in conjunction with --debug (-d)")
|
||||
}
|
||||
|
||||
if noTruncate {
|
||||
utils.SetDisableTruncation(true)
|
||||
logger.Info("String truncation is globally disabled via 'no-truncate' flag")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return gatewayCmd(debug)
|
||||
return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||
cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
|
||||
cmd.Flags().BoolVarP(
|
||||
&allowEmpty,
|
||||
"allow-empty",
|
||||
"E",
|
||||
false,
|
||||
"Continue starting even when no default model is configured",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,4 +28,5 @@ func TestNewGatewayCommand(t *testing.T) {
|
|||
|
||||
assert.True(t, cmd.HasFlags())
|
||||
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,241 +0,0 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/cron"
|
||||
"github.com/sipeed/picoclaw/pkg/devices"
|
||||
"github.com/sipeed/picoclaw/pkg/health"
|
||||
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/state"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
func gatewayCmd(debug bool) error {
|
||||
if debug {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
}
|
||||
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
|
||||
provider, modelID, err := providers.CreateProvider(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating provider: %w", err)
|
||||
}
|
||||
|
||||
// Use the resolved model ID from provider creation
|
||||
if modelID != "" {
|
||||
cfg.Agents.Defaults.ModelName = modelID
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Print agent startup info
|
||||
fmt.Println("\n📦 Agent Status:")
|
||||
startupInfo := agentLoop.GetStartupInfo()
|
||||
toolsInfo := startupInfo["tools"].(map[string]any)
|
||||
skillsInfo := startupInfo["skills"].(map[string]any)
|
||||
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
|
||||
fmt.Printf(" • Skills: %d/%d available\n",
|
||||
skillsInfo["available"],
|
||||
skillsInfo["total"])
|
||||
|
||||
// Log to file as well
|
||||
logger.InfoCF("agent", "Agent initialized",
|
||||
map[string]any{
|
||||
"tools_count": toolsInfo["count"],
|
||||
"skills_total": skillsInfo["total"],
|
||||
"skills_available": skillsInfo["available"],
|
||||
})
|
||||
|
||||
// Setup cron tool and service
|
||||
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
||||
cronService := setupCronTool(
|
||||
agentLoop,
|
||||
msgBus,
|
||||
cfg.WorkspacePath(),
|
||||
cfg.Agents.Defaults.RestrictToWorkspace,
|
||||
execTimeout,
|
||||
cfg,
|
||||
)
|
||||
|
||||
heartbeatService := heartbeat.NewHeartbeatService(
|
||||
cfg.WorkspacePath(),
|
||||
cfg.Heartbeat.Interval,
|
||||
cfg.Heartbeat.Enabled,
|
||||
)
|
||||
heartbeatService.SetBus(msgBus)
|
||||
heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||
// Use cli:direct as fallback if no valid channel
|
||||
if channel == "" || chatID == "" {
|
||||
channel, chatID = "cli", "direct"
|
||||
}
|
||||
// Use ProcessHeartbeat - no session history, each heartbeat is independent
|
||||
var response string
|
||||
response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
||||
if err != nil {
|
||||
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
||||
}
|
||||
if response == "HEARTBEAT_OK" {
|
||||
return tools.SilentResult("Heartbeat OK")
|
||||
}
|
||||
// For heartbeat, always return silent - the subagent result will be
|
||||
// sent to user via processSystemMessage when the async task completes
|
||||
return tools.SilentResult(response)
|
||||
})
|
||||
|
||||
// Create media store for file lifecycle management with TTL cleanup
|
||||
mediaStore := media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
|
||||
Enabled: cfg.Tools.MediaCleanup.Enabled,
|
||||
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
|
||||
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
|
||||
})
|
||||
mediaStore.Start()
|
||||
|
||||
channelManager, err := channels.NewManager(cfg, msgBus, mediaStore)
|
||||
if err != nil {
|
||||
mediaStore.Stop()
|
||||
return fmt.Errorf("error creating channel manager: %w", err)
|
||||
}
|
||||
|
||||
// Inject channel manager and media store into agent loop
|
||||
agentLoop.SetChannelManager(channelManager)
|
||||
agentLoop.SetMediaStore(mediaStore)
|
||||
|
||||
enabledChannels := channelManager.GetEnabledChannels()
|
||||
if len(enabledChannels) > 0 {
|
||||
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
||||
} else {
|
||||
fmt.Println("⚠ Warning: No channels enabled")
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
fmt.Println("Press Ctrl+C to stop")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := cronService.Start(); err != nil {
|
||||
fmt.Printf("Error starting cron service: %v\n", err)
|
||||
}
|
||||
fmt.Println("✓ Cron service started")
|
||||
|
||||
if err := heartbeatService.Start(); err != nil {
|
||||
fmt.Printf("Error starting heartbeat service: %v\n", err)
|
||||
}
|
||||
fmt.Println("✓ Heartbeat service started")
|
||||
|
||||
stateManager := state.NewManager(cfg.WorkspacePath())
|
||||
deviceService := devices.NewService(devices.Config{
|
||||
Enabled: cfg.Devices.Enabled,
|
||||
MonitorUSB: cfg.Devices.MonitorUSB,
|
||||
}, stateManager)
|
||||
deviceService.SetBus(msgBus)
|
||||
if err := deviceService.Start(ctx); err != nil {
|
||||
fmt.Printf("Error starting device service: %v\n", err)
|
||||
} else if cfg.Devices.Enabled {
|
||||
fmt.Println("✓ Device event service started")
|
||||
}
|
||||
|
||||
// Setup shared HTTP server with health endpoints and webhook handlers
|
||||
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
channelManager.SetupHTTPServer(addr, healthServer)
|
||||
|
||||
if err := channelManager.StartAll(ctx); err != nil {
|
||||
fmt.Printf("Error starting channels: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
|
||||
go agentLoop.Run(ctx)
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt)
|
||||
<-sigChan
|
||||
|
||||
fmt.Println("\nShutting down...")
|
||||
if cp, ok := provider.(providers.StatefulProvider); ok {
|
||||
cp.Close()
|
||||
}
|
||||
cancel()
|
||||
msgBus.Close()
|
||||
|
||||
// Use a fresh context with timeout for graceful shutdown,
|
||||
// since the original ctx is already canceled.
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
channelManager.StopAll(shutdownCtx)
|
||||
deviceService.Stop()
|
||||
heartbeatService.Stop()
|
||||
cronService.Stop()
|
||||
mediaStore.Stop()
|
||||
agentLoop.Stop()
|
||||
fmt.Println("✓ Gateway stopped")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupCronTool(
|
||||
agentLoop *agent.AgentLoop,
|
||||
msgBus *bus.MessageBus,
|
||||
workspace string,
|
||||
restrict bool,
|
||||
execTimeout time.Duration,
|
||||
cfg *config.Config,
|
||||
) *cron.CronService {
|
||||
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
||||
|
||||
// Create cron service
|
||||
cronService := cron.NewCronService(cronStorePath, nil)
|
||||
|
||||
// Create and register CronTool
|
||||
cronTool, err := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Critical error during CronTool initialization: %v", err)
|
||||
}
|
||||
|
||||
agentLoop.RegisterTool(cronTool)
|
||||
|
||||
// Set the onJob handler
|
||||
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
||||
result := cronTool.ExecuteJob(context.Background(), job)
|
||||
return result, nil
|
||||
})
|
||||
|
||||
return cronService
|
||||
}
|
||||
|
|
@ -1,55 +1,56 @@
|
|||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
const Logo = "🦞"
|
||||
const Logo = pkg.Logo
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
gitCommit string
|
||||
buildTime string
|
||||
goVersion string
|
||||
)
|
||||
|
||||
func GetConfigPath() string {
|
||||
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
|
||||
return configPath
|
||||
// GetPicoclawHome returns the picoclaw home directory.
|
||||
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||
func GetPicoclawHome() string {
|
||||
if home := os.Getenv(config.EnvHome); home != "" {
|
||||
return home
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".picoclaw", "config.json")
|
||||
return filepath.Join(home, pkg.DefaultPicoClawHome)
|
||||
}
|
||||
|
||||
func GetConfigPath() string {
|
||||
if configPath := os.Getenv(config.EnvConfig); configPath != "" {
|
||||
return configPath
|
||||
}
|
||||
return filepath.Join(GetPicoclawHome(), "config.json")
|
||||
}
|
||||
|
||||
func LoadConfig() (*config.Config, error) {
|
||||
return config.LoadConfig(GetConfigPath())
|
||||
cfg, err := config.LoadConfig(GetConfigPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.SetLevelFromString(cfg.Gateway.LogLevel)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// FormatVersion returns the version string with optional git commit
|
||||
// Deprecated: Use pkg/config.FormatVersion instead
|
||||
func FormatVersion() string {
|
||||
v := version
|
||||
if gitCommit != "" {
|
||||
v += fmt.Sprintf(" (git: %s)", gitCommit)
|
||||
}
|
||||
return v
|
||||
return config.FormatVersion()
|
||||
}
|
||||
|
||||
// FormatBuildInfo returns build time and go version info
|
||||
// Deprecated: Use pkg/config.FormatBuildInfo instead
|
||||
func FormatBuildInfo() (string, string) {
|
||||
build := buildTime
|
||||
goVer := goVersion
|
||||
if goVer == "" {
|
||||
goVer = runtime.Version()
|
||||
}
|
||||
return build, goVer
|
||||
return config.FormatBuildInfo()
|
||||
}
|
||||
|
||||
// GetVersion returns the version string
|
||||
// Deprecated: Use pkg/config.GetVersion instead
|
||||
func GetVersion() string {
|
||||
return version
|
||||
return config.GetVersion()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import (
|
|||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestGetConfigPath(t *testing.T) {
|
||||
|
|
@ -19,63 +21,25 @@ func TestGetConfigPath(t *testing.T) {
|
|||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
||||
oldVersion, oldGit := version, gitCommit
|
||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
||||
func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
|
||||
t.Setenv(config.EnvHome, "/custom/picoclaw")
|
||||
t.Setenv("HOME", "/tmp/home")
|
||||
|
||||
version = "1.2.3"
|
||||
gitCommit = ""
|
||||
got := GetConfigPath()
|
||||
want := filepath.Join("/custom/picoclaw", "config.json")
|
||||
|
||||
assert.Equal(t, "1.2.3", FormatVersion())
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestFormatVersion_WithGitCommit(t *testing.T) {
|
||||
oldVersion, oldGit := version, gitCommit
|
||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
||||
func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
|
||||
t.Setenv(config.EnvHome, "/custom/picoclaw")
|
||||
t.Setenv("HOME", "/tmp/home")
|
||||
|
||||
version = "1.2.3"
|
||||
gitCommit = "abc123"
|
||||
got := GetConfigPath()
|
||||
want := "/custom/config.json"
|
||||
|
||||
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
|
||||
}
|
||||
|
||||
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
|
||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
||||
|
||||
buildTime = "2026-02-20T00:00:00Z"
|
||||
goVersion = "go1.23.0"
|
||||
|
||||
build, goVer := FormatBuildInfo()
|
||||
|
||||
assert.Equal(t, buildTime, build)
|
||||
assert.Equal(t, goVersion, goVer)
|
||||
}
|
||||
|
||||
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
|
||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
||||
|
||||
buildTime = ""
|
||||
goVersion = "go1.23.0"
|
||||
|
||||
build, goVer := FormatBuildInfo()
|
||||
|
||||
assert.Empty(t, build)
|
||||
assert.Equal(t, goVersion, goVer)
|
||||
}
|
||||
|
||||
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
|
||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
||||
|
||||
buildTime = "x"
|
||||
goVersion = ""
|
||||
|
||||
build, goVer := FormatBuildInfo()
|
||||
|
||||
assert.Equal(t, "x", build)
|
||||
assert.Equal(t, runtime.Version(), goVer)
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestGetConfigPath_Windows(t *testing.T) {
|
||||
|
|
@ -91,17 +55,3 @@ func TestGetConfigPath_Windows(t *testing.T) {
|
|||
|
||||
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
func TestGetVersion(t *testing.T) {
|
||||
assert.Equal(t, "dev", GetVersion())
|
||||
}
|
||||
|
||||
func TestGetConfigPath_WithEnv(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json")
|
||||
t.Setenv("HOME", "/tmp/home") // Also set home to ensure env is preferred
|
||||
|
||||
got := GetConfigPath()
|
||||
want := "/tmp/custom/config.json"
|
||||
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
|
|
|||
128
cmd/picoclaw/internal/model/command.go
Normal file
128
cmd/picoclaw/internal/model/command.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// LocalModel is a special model name that indicates that the model is local and with or without api_key.
|
||||
const LocalModel = "local-model"
|
||||
|
||||
func NewModelCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "model [model_name]",
|
||||
Short: "Show or change the default model",
|
||||
Long: `Show or change the default model configuration.
|
||||
|
||||
If no argument is provided, shows the current default model.
|
||||
If a model name is provided, sets it as the default model.
|
||||
|
||||
Examples:
|
||||
picoclaw model # Show current default model
|
||||
picoclaw model gpt-5.2 # Set gpt-5.2 as default
|
||||
picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default
|
||||
picoclaw model local-model # Set local VLLM server as default
|
||||
|
||||
Note: 'local-model' is a special value for using a local VLLM server
|
||||
(running at localhost:8000 by default) which does not require an API key.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configPath := internal.GetConfigPath()
|
||||
|
||||
// Load current config
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
// Show current default model
|
||||
showCurrentModel(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set new default model
|
||||
modelName := args[0]
|
||||
return setDefaultModel(configPath, cfg, modelName)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func showCurrentModel(cfg *config.Config) {
|
||||
defaultModel := cfg.Agents.Defaults.ModelName
|
||||
|
||||
if defaultModel == "" {
|
||||
fmt.Println("No default model is currently set.")
|
||||
fmt.Println("\nAvailable models in your config:")
|
||||
listAvailableModels(cfg)
|
||||
} else {
|
||||
fmt.Printf("Current default model: %s\n", defaultModel)
|
||||
fmt.Println("\nAvailable models in your config:")
|
||||
listAvailableModels(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func listAvailableModels(cfg *config.Config) {
|
||||
if len(cfg.ModelList) == 0 {
|
||||
fmt.Println(" No models configured in model_list")
|
||||
return
|
||||
}
|
||||
|
||||
defaultModel := cfg.Agents.Defaults.ModelName
|
||||
|
||||
for _, model := range cfg.ModelList {
|
||||
marker := " "
|
||||
if model.ModelName == defaultModel {
|
||||
marker = "> "
|
||||
}
|
||||
if model.APIKey() == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func setDefaultModel(configPath string, cfg *config.Config, modelName string) error {
|
||||
// Validate that the model exists in model_list
|
||||
modelFound := false
|
||||
for _, model := range cfg.ModelList {
|
||||
if model.APIKey() != "" && model.ModelName == modelName {
|
||||
modelFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !modelFound && modelName != LocalModel {
|
||||
return fmt.Errorf("cannot found model '%s' in config", modelName)
|
||||
}
|
||||
|
||||
// Update the default model
|
||||
// Clear old model field and set new model_name
|
||||
oldModel := cfg.Agents.Defaults.ModelName
|
||||
|
||||
cfg.Agents.Defaults.ModelName = modelName
|
||||
|
||||
// Save config back to file
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Default model changed from '%s' to '%s'\n",
|
||||
formatModelName(oldModel), modelName)
|
||||
fmt.Println("\nThe new default model will be used for all agent interactions.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatModelName(name string) string {
|
||||
if name == "" {
|
||||
return "(none)"
|
||||
}
|
||||
return name
|
||||
}
|
||||
390
cmd/picoclaw/internal/model/command_test.go
Normal file
390
cmd/picoclaw/internal/model/command_test.go
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
var configPath = ""
|
||||
|
||||
func initTest(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath = filepath.Join(tmpDir, "config.json")
|
||||
_ = os.Setenv("PICOCLAW_CONFIG", configPath)
|
||||
}
|
||||
|
||||
// captureStdout captures stdout during the execution of fn and returns the captured output
|
||||
func captureStdout(fn func()) string {
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
fn()
|
||||
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestNewModelCommand(t *testing.T) {
|
||||
cmd := NewModelCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "model [model_name]", cmd.Use)
|
||||
assert.Equal(t, "Show or change the default model", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRunE)
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
}
|
||||
|
||||
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "gpt-4",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||
{ModelName: "claude-3", Model: "anthropic/claude-3"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"gpt-4": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"claude-3": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
output := captureStdout(func() {
|
||||
showCurrentModel(cfg)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "Current default model: gpt-4")
|
||||
assert.Contains(t, output, "Available models in your config:")
|
||||
assert.Contains(t, output, "gpt-4")
|
||||
assert.Contains(t, output, "claude-3")
|
||||
}
|
||||
|
||||
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"gpt-4": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
output := captureStdout(func() {
|
||||
showCurrentModel(cfg)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "No default model is currently set.")
|
||||
assert.Contains(t, output, "Available models in your config:")
|
||||
}
|
||||
|
||||
func TestListAvailableModels_Empty(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []*config.ModelConfig{},
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
listAvailableModels(cfg)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "No models configured in model_list")
|
||||
}
|
||||
|
||||
func TestListAvailableModels_WithModels(t *testing.T) {
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "gpt-4",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||
{ModelName: "claude-3", Model: "anthropic/claude-3"},
|
||||
{ModelName: "no-key-model", Model: "openai/test"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"gpt-4": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"claude-3": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
output := captureStdout(func() {
|
||||
listAvailableModels(cfg)
|
||||
})
|
||||
|
||||
assert.NotEmpty(t, output)
|
||||
assert.Contains(t, output, "> - gpt-4 (openai/gpt-4)")
|
||||
assert.Contains(t, output, "claude-3 (anthropic/claude-3)")
|
||||
assert.NotContains(t, output, "no-key-model")
|
||||
}
|
||||
|
||||
func TestSetDefaultModel_ValidModel(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "old-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "new-model", Model: "openai/new-model"},
|
||||
{ModelName: "old-model", Model: "openai/old-model"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"new-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"old-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
output := captureStdout(func() {
|
||||
err := setDefaultModel(configPath, cfg, "new-model")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'")
|
||||
|
||||
// Verify config was updated
|
||||
updatedCfg, err := config.LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new-model", updatedCfg.Agents.Defaults.ModelName)
|
||||
}
|
||||
|
||||
func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "existing-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "existing-model", Model: "openai/existing"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"existing-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model"))
|
||||
}
|
||||
|
||||
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "existing-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "existing-model", Model: "openai/existing"},
|
||||
{ModelName: "no-key-model", Model: "openai/nokey"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"existing-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"no-key-model": {
|
||||
APIKeys: []string{""},
|
||||
},
|
||||
}})
|
||||
|
||||
assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model"))
|
||||
}
|
||||
|
||||
func TestSetDefaultModel_SaveConfigError(t *testing.T) {
|
||||
// Use an invalid path to trigger save error
|
||||
invalidPath := "/nonexistent/directory/config.json"
|
||||
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "old-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "new-model", Model: "openai/new-model"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"new-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
err := setDefaultModel(invalidPath, cfg, "new-model")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to save config")
|
||||
}
|
||||
|
||||
func TestFormatModelName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"empty string", "", "(none)"},
|
||||
{"simple model", "gpt-4", "gpt-4"},
|
||||
{"model with version", "claude-sonnet-4.6", "claude-sonnet-4.6"},
|
||||
{"model with spaces", "my model", "my model"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := formatModelName(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelCommandExecution_Show(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
// Create a test config
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "test-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "test-model", Model: "openai/test"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"test-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
err := config.SaveConfig(configPath, cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd := NewModelCommand()
|
||||
|
||||
output := captureStdout(func() {
|
||||
err = cmd.RunE(cmd, []string{})
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "Current default model: test-model")
|
||||
}
|
||||
|
||||
func TestModelCommandExecution_Set(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
sec := &config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"old-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"new-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}}
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "old-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "old-model", Model: "openai/old"},
|
||||
{ModelName: "new-model", Model: "openai/new"},
|
||||
},
|
||||
}).WithSecurity(sec)
|
||||
|
||||
err := config.SaveConfig(configPath, cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd := NewModelCommand()
|
||||
|
||||
output := captureStdout(func() {
|
||||
err = cmd.RunE(cmd, []string{"new-model"})
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'")
|
||||
}
|
||||
|
||||
func TestModelCommandExecution_TooManyArgs(t *testing.T) {
|
||||
cmd := NewModelCommand()
|
||||
|
||||
err := cmd.RunE(cmd, []string{"model1", "model2"})
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestListAvailableModels_MarkerLogic(t *testing.T) {
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "middle-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "first-model", Model: "openai/first"},
|
||||
{ModelName: "middle-model", Model: "openai/middle"},
|
||||
{ModelName: "last-model", Model: "openai/last"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"first-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"middle-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"last-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
output := captureStdout(func() {
|
||||
listAvailableModels(cfg)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, " - first-model (openai/first)")
|
||||
assert.Contains(t, output, "> - middle-model (openai/middle)")
|
||||
assert.Contains(t, output, " - last-model (openai/last)")
|
||||
}
|
||||
|
|
@ -11,14 +11,24 @@ import (
|
|||
var embeddedFiles embed.FS
|
||||
|
||||
func NewOnboardCommand() *cobra.Command {
|
||||
var encrypt bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "onboard",
|
||||
Aliases: []string{"o"},
|
||||
Short: "Initialize picoclaw configuration and workspace",
|
||||
// Run without subcommands → original onboard flow
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
onboard()
|
||||
if len(args) == 0 {
|
||||
onboard(encrypt)
|
||||
} else {
|
||||
_ = cmd.Help()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&encrypt, "enc", false,
|
||||
"Enable credential encryption (generates SSH key and prompts for passphrase)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ func TestNewOnboardCommand(t *testing.T) {
|
|||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
assert.True(t, cmd.HasFlags())
|
||||
encFlag := cmd.Flags().Lookup("enc")
|
||||
require.NotNil(t, encFlag, "expected --enc flag to be registered")
|
||||
assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false")
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,25 +6,71 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/credential"
|
||||
)
|
||||
|
||||
func onboard() {
|
||||
func onboard(encrypt bool) {
|
||||
configPath := internal.GetConfigPath()
|
||||
|
||||
configExists := false
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
configExists = true
|
||||
if encrypt {
|
||||
// Only ask for confirmation when *both* config and SSH key already exist,
|
||||
// indicating a full re-onboard that would reset the config to defaults.
|
||||
sshKeyPath, _ := credential.DefaultSSHKeyPath()
|
||||
if _, err := os.Stat(sshKeyPath); err == nil {
|
||||
// Both exist — confirm a full reset.
|
||||
fmt.Printf("Config already exists at %s\n", configPath)
|
||||
fmt.Print("Overwrite? (y/n): ")
|
||||
fmt.Print("Overwrite config with defaults? (y/n): ")
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
if response != "y" {
|
||||
fmt.Println("Aborted.")
|
||||
return
|
||||
}
|
||||
configExists = false // user agreed to reset; treat as fresh
|
||||
}
|
||||
// Config exists but SSH key is missing — keep existing config, only add SSH key.
|
||||
}
|
||||
}
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
var err error
|
||||
if encrypt {
|
||||
fmt.Println("\nSet up credential encryption")
|
||||
fmt.Println("-----------------------------")
|
||||
passphrase, pErr := promptPassphrase()
|
||||
if pErr != nil {
|
||||
fmt.Printf("Error: %v\n", pErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Expose the passphrase to credential.PassphraseProvider (which calls
|
||||
// os.Getenv by default) so that SaveConfig can encrypt api_keys.
|
||||
// This process is a one-shot CLI tool; the env var is never exposed outside
|
||||
// the current process and disappears when it exits.
|
||||
os.Setenv(credential.PassphraseEnvVar, passphrase)
|
||||
|
||||
if err = setupSSHKey(); err != nil {
|
||||
fmt.Printf("Error generating SSH key: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var cfg *config.Config
|
||||
if configExists {
|
||||
// Preserve the existing config; SaveConfig will re-encrypt api_keys with the new passphrase.
|
||||
cfg, err = config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading existing config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
cfg = config.DefaultConfig()
|
||||
}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
fmt.Printf("Error saving config: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -33,9 +79,17 @@ func onboard() {
|
|||
workspace := cfg.WorkspacePath()
|
||||
createWorkspaceTemplates(workspace)
|
||||
|
||||
fmt.Printf("%s picoclaw is ready!\n", internal.Logo)
|
||||
fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo)
|
||||
fmt.Println("\nNext steps:")
|
||||
if encrypt {
|
||||
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
|
||||
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
|
||||
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
|
||||
fmt.Println("")
|
||||
fmt.Println(" 2. Add your API key to", configPath)
|
||||
} else {
|
||||
fmt.Println(" 1. Add your API key to", configPath)
|
||||
}
|
||||
fmt.Println("")
|
||||
fmt.Println(" Recommended:")
|
||||
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
|
||||
|
|
@ -43,7 +97,62 @@ func onboard() {
|
|||
fmt.Println("")
|
||||
fmt.Println(" See README.md for 17+ supported providers.")
|
||||
fmt.Println("")
|
||||
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
||||
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
|
||||
}
|
||||
|
||||
// promptPassphrase reads the encryption passphrase twice from the terminal
|
||||
// (with echo disabled) and returns it. Returns an error if the passphrase is
|
||||
// empty or if the two inputs do not match.
|
||||
func promptPassphrase() (string, error) {
|
||||
fmt.Print("Enter passphrase for credential encryption: ")
|
||||
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading passphrase: %w", err)
|
||||
}
|
||||
if len(p1) == 0 {
|
||||
return "", fmt.Errorf("passphrase must not be empty")
|
||||
}
|
||||
|
||||
fmt.Print("Confirm passphrase: ")
|
||||
p2, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading passphrase confirmation: %w", err)
|
||||
}
|
||||
|
||||
if string(p1) != string(p2) {
|
||||
return "", fmt.Errorf("passphrases do not match")
|
||||
}
|
||||
return string(p1), nil
|
||||
}
|
||||
|
||||
// setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key.
|
||||
// If the key already exists the user is warned and asked to confirm overwrite.
|
||||
// Answering anything other than "y" keeps the existing key (not an error).
|
||||
func setupSSHKey() error {
|
||||
keyPath, err := credential.DefaultSSHKeyPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot determine SSH key path: %w", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
fmt.Printf("\n⚠️ WARNING: %s already exists.\n", keyPath)
|
||||
fmt.Println(" Overwriting will invalidate any credentials previously encrypted with this key.")
|
||||
fmt.Print(" Overwrite? (y/n): ")
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
if response != "y" {
|
||||
fmt.Println("Keeping existing SSH key.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := credential.GenerateSSHKey(keyPath); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("SSH key generated: %s\n", keyPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func createWorkspaceTemplates(workspace string) {
|
||||
|
|
|
|||
|
|
@ -6,20 +6,32 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
func TestCopyEmbeddedToTargetUsesAgentsMarkdown(t *testing.T) {
|
||||
func TestCopyEmbeddedToTargetUsesStructuredAgentFiles(t *testing.T) {
|
||||
targetDir := t.TempDir()
|
||||
|
||||
if err := copyEmbeddedToTarget(targetDir); err != nil {
|
||||
t.Fatalf("copyEmbeddedToTarget() error = %v", err)
|
||||
}
|
||||
|
||||
agentsPath := filepath.Join(targetDir, "AGENTS.md")
|
||||
if _, err := os.Stat(agentsPath); err != nil {
|
||||
t.Fatalf("expected %s to exist: %v", agentsPath, err)
|
||||
agentPath := filepath.Join(targetDir, "AGENT.md")
|
||||
if _, err := os.Stat(agentPath); err != nil {
|
||||
t.Fatalf("expected %s to exist: %v", agentPath, err)
|
||||
}
|
||||
|
||||
legacyPath := filepath.Join(targetDir, "AGENT.md")
|
||||
soulPath := filepath.Join(targetDir, "SOUL.md")
|
||||
if _, err := os.Stat(soulPath); err != nil {
|
||||
t.Fatalf("expected %s to exist: %v", soulPath, err)
|
||||
}
|
||||
|
||||
userPath := filepath.Join(targetDir, "USER.md")
|
||||
if _, err := os.Stat(userPath); err != nil {
|
||||
t.Fatalf("expected %s to exist: %v", userPath, err)
|
||||
}
|
||||
|
||||
for _, legacyName := range []string{"AGENTS.md", "IDENTITY.md"} {
|
||||
legacyPath := filepath.Join(targetDir, legacyName)
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,15 @@ func NewSkillsCommand() *cobra.Command {
|
|||
}
|
||||
|
||||
d.workspace = cfg.WorkspacePath()
|
||||
d.installer = skills.NewSkillInstaller(d.workspace)
|
||||
installer, err := skills.NewSkillInstaller(
|
||||
d.workspace,
|
||||
cfg.Tools.Skills.Github.Token(),
|
||||
cfg.Tools.Skills.Github.Proxy,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating skills installer: %w", err)
|
||||
}
|
||||
d.installer = installer
|
||||
|
||||
// get global config directory and builtin skills directory
|
||||
globalDir := filepath.Dir(internal.GetConfigPath())
|
||||
|
|
|
|||
|
|
@ -64,9 +64,20 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
|||
|
||||
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
||||
|
||||
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
||||
ClawHub: skills.ClawHubConfig{
|
||||
Enabled: clawHubConfig.Enabled,
|
||||
BaseURL: clawHubConfig.BaseURL,
|
||||
AuthToken: clawHubConfig.AuthToken(),
|
||||
SearchPath: clawHubConfig.SearchPath,
|
||||
SkillsPath: clawHubConfig.SkillsPath,
|
||||
DownloadPath: clawHubConfig.DownloadPath,
|
||||
Timeout: clawHubConfig.Timeout,
|
||||
MaxZipSize: clawHubConfig.MaxZipSize,
|
||||
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
||||
},
|
||||
})
|
||||
|
||||
registry := registryMgr.GetRegistry(registryName)
|
||||
|
|
@ -226,9 +237,20 @@ func skillsSearchCmd(query string) {
|
|||
return
|
||||
}
|
||||
|
||||
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
||||
ClawHub: skills.ClawHubConfig{
|
||||
Enabled: clawHubConfig.Enabled,
|
||||
BaseURL: clawHubConfig.BaseURL,
|
||||
AuthToken: clawHubConfig.AuthToken(),
|
||||
SearchPath: clawHubConfig.SearchPath,
|
||||
SkillsPath: clawHubConfig.SkillsPath,
|
||||
DownloadPath: clawHubConfig.DownloadPath,
|
||||
Timeout: clawHubConfig.Timeout,
|
||||
MaxZipSize: clawHubConfig.MaxZipSize,
|
||||
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ picoclaw skills install --registry clawhub github
|
|||
`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if registry != "" {
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("when --registry is set, exactly 2 arguments are required: <name> <slug>")
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("when --registry is set, exactly 1 argument is required: <slug>")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ picoclaw skills install --registry clawhub github
|
|||
return err
|
||||
}
|
||||
|
||||
return skillsInstallFromRegistry(cfg, args[0], args[1])
|
||||
return skillsInstallFromRegistry(cfg, registry, args[0])
|
||||
}
|
||||
|
||||
return skillsInstallCmd(installer, args[0])
|
||||
|
|
|
|||
|
|
@ -26,3 +26,72 @@ func TestNewInstallSubcommand(t *testing.T) {
|
|||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
}
|
||||
|
||||
func TestInstallCommandArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
registry string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "no registry, one arg",
|
||||
args: []string{"sipeed/picoclaw-skills/weather"},
|
||||
registry: "",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "no registry, no args",
|
||||
args: []string{},
|
||||
registry: "",
|
||||
expectError: true,
|
||||
errorMsg: "exactly 1 argument is required: <github>",
|
||||
},
|
||||
{
|
||||
name: "no registry, too many args",
|
||||
args: []string{"arg1", "arg2"},
|
||||
registry: "",
|
||||
expectError: true,
|
||||
errorMsg: "exactly 1 argument is required: <github>",
|
||||
},
|
||||
{
|
||||
name: "with registry, one arg",
|
||||
args: []string{"weather-skill"},
|
||||
registry: "clawhub",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "with registry, no args",
|
||||
args: []string{},
|
||||
registry: "clawhub",
|
||||
expectError: true,
|
||||
errorMsg: "when --registry is set, exactly 1 argument is required: <slug>",
|
||||
},
|
||||
{
|
||||
name: "with registry, too many args",
|
||||
args: []string{"arg1", "arg2"},
|
||||
registry: "clawhub",
|
||||
expectError: true,
|
||||
errorMsg: "when --registry is set, exactly 1 argument is required: <slug>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := newInstallCommand(nil)
|
||||
|
||||
if tt.registry != "" {
|
||||
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
||||
}
|
||||
|
||||
err := cmd.Args(cmd, tt.args)
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tt.errorMsg, err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func statusCmd() {
|
||||
|
|
@ -18,8 +19,8 @@ func statusCmd() {
|
|||
configPath := internal.GetConfigPath()
|
||||
|
||||
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
||||
fmt.Printf("Version: %s\n", internal.FormatVersion())
|
||||
build, _ := internal.FormatBuildInfo()
|
||||
fmt.Printf("Version: %s\n", config.FormatVersion())
|
||||
build, _ := config.FormatBuildInfo()
|
||||
if build != "" {
|
||||
fmt.Printf("Build: %s\n", build)
|
||||
}
|
||||
|
|
@ -41,48 +42,6 @@ func statusCmd() {
|
|||
if _, err := os.Stat(configPath); err == nil {
|
||||
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
|
||||
|
||||
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
|
||||
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
|
||||
hasOpenAI := cfg.Providers.OpenAI.APIKey != ""
|
||||
hasGemini := cfg.Providers.Gemini.APIKey != ""
|
||||
hasZhipu := cfg.Providers.Zhipu.APIKey != ""
|
||||
hasQwen := cfg.Providers.Qwen.APIKey != ""
|
||||
hasGroq := cfg.Providers.Groq.APIKey != ""
|
||||
hasVLLM := cfg.Providers.VLLM.APIBase != ""
|
||||
hasMoonshot := cfg.Providers.Moonshot.APIKey != ""
|
||||
hasDeepSeek := cfg.Providers.DeepSeek.APIKey != ""
|
||||
hasVolcEngine := cfg.Providers.VolcEngine.APIKey != ""
|
||||
hasNvidia := cfg.Providers.Nvidia.APIKey != ""
|
||||
hasOllama := cfg.Providers.Ollama.APIBase != ""
|
||||
|
||||
status := func(enabled bool) string {
|
||||
if enabled {
|
||||
return "✓"
|
||||
}
|
||||
return "not set"
|
||||
}
|
||||
fmt.Println("OpenRouter API:", status(hasOpenRouter))
|
||||
fmt.Println("Anthropic API:", status(hasAnthropic))
|
||||
fmt.Println("OpenAI API:", status(hasOpenAI))
|
||||
fmt.Println("Gemini API:", status(hasGemini))
|
||||
fmt.Println("Zhipu API:", status(hasZhipu))
|
||||
fmt.Println("Qwen API:", status(hasQwen))
|
||||
fmt.Println("Groq API:", status(hasGroq))
|
||||
fmt.Println("Moonshot API:", status(hasMoonshot))
|
||||
fmt.Println("DeepSeek API:", status(hasDeepSeek))
|
||||
fmt.Println("VolcEngine API:", status(hasVolcEngine))
|
||||
fmt.Println("Nvidia API:", status(hasNvidia))
|
||||
if hasVLLM {
|
||||
fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase)
|
||||
} else {
|
||||
fmt.Println("vLLM/Local: not set")
|
||||
}
|
||||
if hasOllama {
|
||||
fmt.Printf("Ollama: ✓ %s\n", cfg.Providers.Ollama.APIBase)
|
||||
} else {
|
||||
fmt.Println("Ollama: not set")
|
||||
}
|
||||
|
||||
store, _ := auth.LoadStore()
|
||||
if store != nil && len(store.Credentials) > 0 {
|
||||
fmt.Println("\nOAuth/Token Auth:")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func NewVersionCommand() *cobra.Command {
|
||||
|
|
@ -22,8 +23,8 @@ func NewVersionCommand() *cobra.Command {
|
|||
}
|
||||
|
||||
func printVersion() {
|
||||
fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion())
|
||||
build, goVer := internal.FormatBuildInfo()
|
||||
fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion())
|
||||
build, goVer := config.FormatBuildInfo()
|
||||
if build != "" {
|
||||
fmt.Printf(" Build: %s\n", build)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,19 +18,21 @@ import (
|
|||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/model"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func NewPicoclawCommand() *cobra.Command {
|
||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
|
||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion())
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "picoclaw",
|
||||
Short: short,
|
||||
Example: "picoclaw list",
|
||||
Example: "picoclaw version",
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
|
|
@ -42,13 +44,28 @@ func NewPicoclawCommand() *cobra.Command {
|
|||
cron.NewCronCommand(),
|
||||
migrate.NewMigrateCommand(),
|
||||
skills.NewSkillsCommand(),
|
||||
model.NewModelCommand(),
|
||||
version.NewVersionCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
const (
|
||||
colorBlue = "\033[1;38;2;62;93;185m"
|
||||
colorRed = "\033[1;38;2;213;70;70m"
|
||||
banner = "\r\n" +
|
||||
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
||||
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
||||
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
|
||||
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
|
||||
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
||||
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
|
||||
"\033[0m\r\n"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Printf("%s", banner)
|
||||
cmd := NewPicoclawCommand()
|
||||
if err := cmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestNewPicoclawCommand(t *testing.T) {
|
||||
|
|
@ -16,7 +17,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
|||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
|
||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion())
|
||||
|
||||
assert.Equal(t, "picoclaw", cmd.Use)
|
||||
assert.Equal(t, short, cmd.Short)
|
||||
|
|
@ -38,6 +39,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
|||
"cron",
|
||||
"gateway",
|
||||
"migrate",
|
||||
"model",
|
||||
"onboard",
|
||||
"skills",
|
||||
"status",
|
||||
|
|
|
|||
|
|
@ -3,16 +3,23 @@
|
|||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true,
|
||||
"model_name": "gpt4",
|
||||
"model_name": "gpt-5.4",
|
||||
"max_tokens": 8192,
|
||||
"context_window": 131072,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
"max_tool_iterations": 20,
|
||||
"summarize_message_threshold": 20,
|
||||
"summarize_token_percent": 75,
|
||||
"tool_feedback": {
|
||||
"enabled": false,
|
||||
"max_args_length": 300
|
||||
}
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-openai-key",
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
},
|
||||
|
|
@ -20,7 +27,15 @@
|
|||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "sk-ant-your-key",
|
||||
"api_base": "https://api.anthropic.com/v1"
|
||||
"api_base": "https://api.anthropic.com/v1",
|
||||
"thinking_level": "high"
|
||||
},
|
||||
{
|
||||
"_comment": "Anthropic Messages API - use native format for direct Anthropic API access",
|
||||
"model_name": "claude-opus-4-6",
|
||||
"model": "anthropic-messages/claude-opus-4-6",
|
||||
"api_key": "sk-ant-your-key",
|
||||
"api_base": "https://api.anthropic.com"
|
||||
},
|
||||
{
|
||||
"model_name": "gemini",
|
||||
|
|
@ -33,14 +48,31 @@
|
|||
"api_key": "sk-your-deepseek-key"
|
||||
},
|
||||
{
|
||||
"model_name": "loadbalanced-gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "longcat",
|
||||
"model": "longcat/LongCat-Flash-Thinking",
|
||||
"api_key": "your-longcat-api-key"
|
||||
},
|
||||
{
|
||||
"model_name": "modelscope-qwen",
|
||||
"model": "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
"api_key": "your-modelscope-access-token",
|
||||
"api_base": "https://api-inference.modelscope.cn/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "azure-gpt5",
|
||||
"model": "azure/my-gpt5-deployment",
|
||||
"api_key": "your-azure-api-key",
|
||||
"api_base": "https://your-resource.openai.azure.com"
|
||||
},
|
||||
{
|
||||
"model_name": "loadbalanced-gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-key1",
|
||||
"api_base": "https://api1.example.com/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "loadbalanced-gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"model_name": "loadbalanced-gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-key2",
|
||||
"api_base": "https://api2.example.com/v1"
|
||||
}
|
||||
|
|
@ -49,15 +81,19 @@
|
|||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||
"base_url": "",
|
||||
"proxy": "",
|
||||
"allow_from": [
|
||||
"YOUR_USER_ID"
|
||||
],
|
||||
"reasoning_channel_id": ""
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"use_markdown_v2": false,
|
||||
"reasoning_channel_id": "",
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"enabled": false,
|
||||
"token": "YOUR_DISCORD_BOT_TOKEN",
|
||||
"proxy": "",
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": false
|
||||
|
|
@ -93,7 +129,9 @@
|
|||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
"reasoning_channel_id": "",
|
||||
"random_reaction_emoji": [],
|
||||
"is_lark": false
|
||||
},
|
||||
"dingtalk": {
|
||||
"enabled": false,
|
||||
|
|
@ -109,6 +147,25 @@
|
|||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
},
|
||||
"matrix": {
|
||||
"enabled": false,
|
||||
"homeserver": "https://matrix.org",
|
||||
"user_id": "@your-bot:matrix.org",
|
||||
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||
"device_id": "",
|
||||
"join_on_invite": true,
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": true
|
||||
},
|
||||
"placeholder": {
|
||||
"enabled": true,
|
||||
"text": "Thinking... 💭"
|
||||
},
|
||||
"reasoning_channel_id": "",
|
||||
"crypto_database_path": "",
|
||||
"crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY"
|
||||
},
|
||||
"line": {
|
||||
"enabled": false,
|
||||
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
||||
|
|
@ -127,27 +184,59 @@
|
|||
"reasoning_channel_id": ""
|
||||
},
|
||||
"wecom": {
|
||||
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
|
||||
"_comment": "WeCom AI Bot over WebSocket.",
|
||||
"enabled": false,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"bot_id": "YOUR_BOT_ID",
|
||||
"secret": "YOUR_SECRET",
|
||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||
"send_thinking_message": true,
|
||||
"allow_from": [],
|
||||
"reply_timeout": 5,
|
||||
"reasoning_channel_id": ""
|
||||
},
|
||||
"wecom_app": {
|
||||
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
|
||||
"pico": {
|
||||
"enabled": false,
|
||||
"corp_id": "YOUR_CORP_ID",
|
||||
"corp_secret": "YOUR_CORP_SECRET",
|
||||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"token": "YOUR_PICO_TOKEN",
|
||||
"allow_token_query": false,
|
||||
"allow_origins": [],
|
||||
"ping_interval": 30,
|
||||
"read_timeout": 60,
|
||||
"max_connections": 100,
|
||||
"allow_from": []
|
||||
},
|
||||
"pico_client": {
|
||||
"enabled": false,
|
||||
"url": "wss://remote-pico-server/pico/ws",
|
||||
"token": "YOUR_PICO_TOKEN",
|
||||
"session_id": "",
|
||||
"ping_interval": 30,
|
||||
"read_timeout": 60,
|
||||
"allow_from": []
|
||||
},
|
||||
"irc": {
|
||||
"enabled": false,
|
||||
"server": "irc.libera.chat:6697",
|
||||
"tls": true,
|
||||
"nick": "mybot",
|
||||
"user": "",
|
||||
"real_name": "",
|
||||
"password": "",
|
||||
"nickserv_password": "",
|
||||
"sasl_user": "",
|
||||
"sasl_password": "",
|
||||
"channels": [
|
||||
"#mychannel"
|
||||
],
|
||||
"request_caps": [
|
||||
"server-time",
|
||||
"message-tags"
|
||||
],
|
||||
"allow_from": [],
|
||||
"reply_timeout": 5,
|
||||
"group_trigger": {
|
||||
"mention_only": true
|
||||
},
|
||||
"typing": {
|
||||
"enabled": false
|
||||
},
|
||||
"reasoning_channel_id": ""
|
||||
}
|
||||
},
|
||||
|
|
@ -210,15 +299,42 @@
|
|||
"mistral": {
|
||||
"api_key": "",
|
||||
"api_base": "https://api.mistral.ai/v1"
|
||||
},
|
||||
"avian": {
|
||||
"api_key": "",
|
||||
"api_base": "https://api.avian.io/v1"
|
||||
},
|
||||
"longcat": {
|
||||
"api_key": "",
|
||||
"api_base": "https://api.longcat.chat/openai"
|
||||
},
|
||||
"modelscope": {
|
||||
"api_key": "",
|
||||
"api_base": "https://api-inference.modelscope.cn/v1"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"allow_read_paths": null,
|
||||
"allow_write_paths": null,
|
||||
"web": {
|
||||
"enabled": true,
|
||||
"prefer_native": true,
|
||||
"fetch_limit_bytes": 10485760,
|
||||
"format": "plaintext",
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"api_keys": [
|
||||
"YOUR_BRAVE_API_KEY"
|
||||
],
|
||||
"max_results": 5
|
||||
},
|
||||
"tavily": {
|
||||
"enabled": false,
|
||||
"api_key": "",
|
||||
"base_url": "",
|
||||
"max_results": 0
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
|
|
@ -226,27 +342,176 @@
|
|||
"perplexity": {
|
||||
"enabled": false,
|
||||
"api_key": "pplx-xxx",
|
||||
"api_keys": [
|
||||
"pplx-xxx"
|
||||
],
|
||||
"max_results": 5
|
||||
},
|
||||
"proxy": ""
|
||||
"searxng": {
|
||||
"enabled": false,
|
||||
"base_url": "http://localhost:8888",
|
||||
"max_results": 5
|
||||
},
|
||||
"glm_search": {
|
||||
"enabled": false,
|
||||
"api_key": "",
|
||||
"base_url": "https://open.bigmodel.cn/api/paas/v4/web_search",
|
||||
"search_engine": "search_std",
|
||||
"max_results": 5
|
||||
},
|
||||
"fetch_limit_bytes": 10485760,
|
||||
"private_host_whitelist": []
|
||||
},
|
||||
"cron": {
|
||||
"enabled": true,
|
||||
"exec_timeout_minutes": 5
|
||||
},
|
||||
"mcp": {
|
||||
"enabled": false,
|
||||
"discovery": {
|
||||
"enabled": false,
|
||||
"ttl": 5,
|
||||
"max_search_results": 5,
|
||||
"use_bm25": true,
|
||||
"use_regex": false
|
||||
},
|
||||
"servers": {
|
||||
"context7": {
|
||||
"enabled": false,
|
||||
"type": "http",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
"headers": {
|
||||
"CONTEXT7_API_KEY": "ctx7sk-xx"
|
||||
}
|
||||
},
|
||||
"filesystem": {
|
||||
"enabled": false,
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp"
|
||||
]
|
||||
},
|
||||
"github": {
|
||||
"enabled": false,
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-github"
|
||||
],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
|
||||
}
|
||||
},
|
||||
"brave-search": {
|
||||
"enabled": false,
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-brave-search"
|
||||
],
|
||||
"env": {
|
||||
"BRAVE_API_KEY": "YOUR_BRAVE_API_KEY"
|
||||
}
|
||||
},
|
||||
"postgres": {
|
||||
"enabled": false,
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-postgres",
|
||||
"postgresql://user:password@localhost/dbname"
|
||||
]
|
||||
},
|
||||
"slack": {
|
||||
"enabled": false,
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-slack"
|
||||
],
|
||||
"env": {
|
||||
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
|
||||
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exec": {
|
||||
"enable_deny_patterns": false,
|
||||
"custom_deny_patterns": []
|
||||
"enabled": true,
|
||||
"enable_deny_patterns": true,
|
||||
"custom_deny_patterns": null,
|
||||
"custom_allow_patterns": null
|
||||
},
|
||||
"skills": {
|
||||
"enabled": true,
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"enabled": true,
|
||||
"base_url": "https://clawhub.ai",
|
||||
"search_path": "/api/v1/search",
|
||||
"skills_path": "/api/v1/skills",
|
||||
"download_path": "/api/v1/download"
|
||||
"auth_token": "",
|
||||
"search_path": "",
|
||||
"skills_path": "",
|
||||
"download_path": "",
|
||||
"timeout": 0,
|
||||
"max_zip_size": 0,
|
||||
"max_response_size": 0
|
||||
}
|
||||
},
|
||||
"github": {
|
||||
"proxy": "http://127.0.0.1:7891",
|
||||
"token": ""
|
||||
},
|
||||
"max_concurrent_searches": 2,
|
||||
"search_cache": {
|
||||
"max_size": 50,
|
||||
"ttl_seconds": 300
|
||||
}
|
||||
},
|
||||
"media_cleanup": {
|
||||
"enabled": true,
|
||||
"max_age_minutes": 30,
|
||||
"interval_minutes": 5
|
||||
},
|
||||
"append_file": {
|
||||
"enabled": true
|
||||
},
|
||||
"edit_file": {
|
||||
"enabled": true
|
||||
},
|
||||
"find_skills": {
|
||||
"enabled": true
|
||||
},
|
||||
"i2c": {
|
||||
"enabled": false
|
||||
},
|
||||
"install_skill": {
|
||||
"enabled": true
|
||||
},
|
||||
"list_dir": {
|
||||
"enabled": true
|
||||
},
|
||||
"message": {
|
||||
"enabled": true
|
||||
},
|
||||
"read_file": {
|
||||
"enabled": true
|
||||
},
|
||||
"spawn": {
|
||||
"enabled": true
|
||||
},
|
||||
"spi": {
|
||||
"enabled": false
|
||||
},
|
||||
"subagent": {
|
||||
"enabled": true
|
||||
},
|
||||
"web_fetch": {
|
||||
"enabled": true
|
||||
},
|
||||
"write_file": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
|
|
@ -257,8 +522,23 @@
|
|||
"enabled": false,
|
||||
"monitor_usb": true
|
||||
},
|
||||
"voice": {
|
||||
"model_name": "",
|
||||
"echo_transcription": false
|
||||
},
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"defaults": {
|
||||
"observer_timeout_ms": 500,
|
||||
"interceptor_timeout_ms": 5000,
|
||||
"approval_timeout_ms": 60000
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
|
||||
"host": "127.0.0.1",
|
||||
"port": 18790
|
||||
"port": 18790,
|
||||
"hot_reload": false,
|
||||
"log_level": "fatal"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
44
docker/Dockerfile.full
Normal file
44
docker/Dockerfile.full
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# ============================================================
|
||||
# Stage 1: Build the picoclaw binary
|
||||
# ============================================================
|
||||
FROM golang:1.26.0-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Cache dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN make build
|
||||
|
||||
# ============================================================
|
||||
# Stage 2: Node.js-based runtime with full MCP support
|
||||
# ============================================================
|
||||
FROM node:24-alpine3.23
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
python3 \
|
||||
py3-pip
|
||||
|
||||
# Install uv and symlink to system path
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||
ln -s /root/.local/bin/uv /usr/local/bin/uv && \
|
||||
ln -s /root/.local/bin/uvx /usr/local/bin/uvx && \
|
||||
uv --version
|
||||
|
||||
# Copy binary
|
||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||
|
||||
# Create picoclaw home directory
|
||||
RUN /usr/local/bin/picoclaw onboard
|
||||
|
||||
ENTRYPOINT ["picoclaw"]
|
||||
CMD ["gateway"]
|
||||
12
docker/Dockerfile.goreleaser.launcher
Normal file
12
docker/Dockerfile.goreleaser.launcher
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
FROM alpine:3.21
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
||||
COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher
|
||||
COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui
|
||||
|
||||
ENTRYPOINT ["picoclaw-launcher"]
|
||||
CMD ["-public", "-no-browser"]
|
||||
67
docker/Dockerfile.heavy
Normal file
67
docker/Dockerfile.heavy
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# ============================================================
|
||||
# Stage 1: Build the picoclaw binary
|
||||
# ============================================================
|
||||
FROM golang:1.26.0-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Cache dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN make build
|
||||
|
||||
# ============================================================
|
||||
# Stage 2: Node.js runtime with Python + MCP support
|
||||
# ============================================================
|
||||
FROM node:24-alpine3.23
|
||||
|
||||
RUN apk add --no-cache \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
python3 \
|
||||
py3-pip \
|
||||
chromium \
|
||||
jq
|
||||
|
||||
# Install Playwright browsers for agent-browser
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers
|
||||
RUN npm install -g agent-browser && \
|
||||
npx playwright install chromium && \
|
||||
chmod -R o+rx $PLAYWRIGHT_BROWSERS_PATH
|
||||
|
||||
# Install uv
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||
ln -s /root/.local/bin/uv /usr/local/bin/uv && \
|
||||
ln -s /root/.local/bin/uvx /usr/local/bin/uvx && \
|
||||
uv --version
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:18790/health || exit 1
|
||||
|
||||
# Copy binary
|
||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||
|
||||
# Reuse existing node user (UID/GID 1000) — rename to picoclaw
|
||||
RUN deluser node 2>/dev/null; delgroup node 2>/dev/null; \
|
||||
addgroup -g 1000 picoclaw 2>/dev/null; \
|
||||
adduser -D -u 1000 -G picoclaw -h /home/picoclaw picoclaw 2>/dev/null || true
|
||||
|
||||
USER picoclaw
|
||||
|
||||
# Run onboard to create initial directories and config
|
||||
RUN /usr/local/bin/picoclaw onboard
|
||||
|
||||
# Copy default workspace
|
||||
COPY --chown=picoclaw:picoclaw workspace/ /home/picoclaw/.picoclaw/workspace/
|
||||
|
||||
VOLUME /home/picoclaw/.picoclaw/workspace
|
||||
|
||||
ENTRYPOINT ["picoclaw"]
|
||||
CMD ["gateway"]
|
||||
44
docker/docker-compose.full.yml
Normal file
44
docker/docker-compose.full.yml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
services:
|
||||
# ─────────────────────────────────────────────
|
||||
# PicoClaw Agent (one-shot query) - Full MCP Support
|
||||
# docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent -m "Hello"
|
||||
# ─────────────────────────────────────────────
|
||||
picoclaw-agent:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.full
|
||||
container_name: picoclaw-agent-full
|
||||
profiles:
|
||||
- agent
|
||||
volumes:
|
||||
- ../config/config.json:/root/.picoclaw/config.json:ro
|
||||
- picoclaw-workspace:/root/.picoclaw/workspace
|
||||
- picoclaw-npm-cache:/root/.npm # npm cache for faster MCP server installs
|
||||
entrypoint: ["picoclaw", "agent"]
|
||||
stdin_open: true
|
||||
tty: true
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# PicoClaw Gateway (Long-running Bot) - Full MCP Support
|
||||
# docker compose -f docker/docker-compose.full.yml --profile gateway up
|
||||
# ─────────────────────────────────────────────
|
||||
picoclaw-gateway:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.full
|
||||
container_name: picoclaw-gateway-full
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- gateway
|
||||
volumes:
|
||||
# Configuration file
|
||||
- ../config/config.json:/root/.picoclaw/config.json:ro
|
||||
# Persistent workspace (sessions, memory, logs)
|
||||
- picoclaw-workspace:/root/.picoclaw/workspace
|
||||
# NPM cache for faster MCP server installs
|
||||
- picoclaw-npm-cache:/root/.npm
|
||||
command: ["gateway"]
|
||||
|
||||
volumes:
|
||||
picoclaw-workspace:
|
||||
picoclaw-npm-cache: # Cache npm packages to speed up MCP server installations
|
||||
|
|
@ -19,7 +19,7 @@ services:
|
|||
|
||||
# ─────────────────────────────────────────────
|
||||
# PicoClaw Gateway (Long-running Bot)
|
||||
# docker compose -f docker/docker-compose.yml up picoclaw-gateway
|
||||
# docker compose -f docker/docker-compose.yml --profile gateway up
|
||||
# ─────────────────────────────────────────────
|
||||
picoclaw-gateway:
|
||||
image: docker.io/sipeed/picoclaw:latest
|
||||
|
|
@ -32,3 +32,21 @@ services:
|
|||
# - "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- ./data:/root/.picoclaw
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# PicoClaw Launcher (Web Console + Gateway)
|
||||
# docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# ─────────────────────────────────────────────
|
||||
picoclaw-launcher:
|
||||
image: docker.io/sipeed/picoclaw:launcher
|
||||
container_name: picoclaw-launcher
|
||||
restart: on-failure
|
||||
profiles:
|
||||
- launcher
|
||||
environment:
|
||||
- PICOCLAW_GATEWAY_HOST=0.0.0.0
|
||||
ports:
|
||||
- "127.0.0.1:18800:18800"
|
||||
- "127.0.0.1:18790:18790"
|
||||
volumes:
|
||||
- ./data:/root/.picoclaw
|
||||
|
|
|
|||
|
|
@ -438,7 +438,7 @@ type ProviderAuthResult = {
|
|||
|
||||
### 1. Required Environment/Dependencies
|
||||
|
||||
- Go ≥ 1.21
|
||||
- Go ≥ 1.25
|
||||
- PicoClaw codebase (`pkg/providers/` and `pkg/auth/`)
|
||||
- `crypto` and `net/http` standard library packages
|
||||
|
||||
|
|
@ -584,7 +584,7 @@ Each SSE message (`data: {...}`) is wrapped in a `response` field:
|
|||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "gemini-flash"
|
||||
"model_name": "gemini-flash"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -674,7 +674,7 @@ Add a default entry in `pkg/config/defaults.go`:
|
|||
|
||||
#### 5. Add Auth Support (Optional)
|
||||
|
||||
If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/cmd_auth.go`:
|
||||
If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/internal/auth/helpers.go`:
|
||||
|
||||
```go
|
||||
case "your-provider":
|
||||
|
|
@ -736,7 +736,7 @@ export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/m
|
|||
- `pkg/auth/store.go` - Auth credential storage (`~/.picoclaw/auth.json`)
|
||||
- `pkg/providers/factory.go` - Provider factory and protocol routing
|
||||
- `pkg/providers/types.go` - Provider interface definitions
|
||||
- `cmd/picoclaw/cmd_auth.go` - Auth CLI commands
|
||||
- `cmd/picoclaw/internal/auth/helpers.go` - Auth CLI commands
|
||||
|
||||
- **Documentation:**
|
||||
- `docs/ANTIGRAVITY_USAGE.md` - Antigravity usage guide
|
||||
|
|
|
|||
145
docs/agent-refactor/README.md
Normal file
145
docs/agent-refactor/README.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Agent Refactor
|
||||
|
||||
## What this directory is for
|
||||
|
||||
This directory is the working area for the current Agent refactor.
|
||||
|
||||
The purpose of this refactor is simple:
|
||||
|
||||
the project needs a smaller, clearer, and more stable Agent model before more Agent-related behavior is added.
|
||||
|
||||
The codebase already contains meaningful Agent behavior. What it still lacks is a sufficiently explicit and stable semantic boundary around that behavior.
|
||||
|
||||
This refactor exists to fix that first.
|
||||
|
||||
---
|
||||
|
||||
## Refactor stance
|
||||
|
||||
This is a maintenance-led consolidation effort.
|
||||
|
||||
It is not a general invitation to expand Agent behavior in parallel.
|
||||
|
||||
During this refactor window, Agent-related work should converge on the current refactor track instead of branching into new semantics.
|
||||
|
||||
That means:
|
||||
|
||||
- concept clarification before feature expansion
|
||||
- boundary tightening before abstraction growth
|
||||
- semantic consolidation before new behavior
|
||||
|
||||
---
|
||||
|
||||
## Core rule: minimum concepts only
|
||||
|
||||
This refactor follows one hard rule:
|
||||
|
||||
**do not introduce a new concept unless it is strictly necessary**
|
||||
|
||||
More explicitly:
|
||||
|
||||
- if an existing concept can be clarified, reuse it
|
||||
- if an existing boundary can be made explicit, do that first
|
||||
- if a behavior can be expressed without a new abstraction, do not add one
|
||||
- "future flexibility" is not enough justification on its own
|
||||
|
||||
The goal of this refactor is not to grow the model.
|
||||
|
||||
The goal is to reduce ambiguity.
|
||||
|
||||
---
|
||||
|
||||
## What is being clarified
|
||||
|
||||
This refactor is currently concerned with the following questions:
|
||||
|
||||
1. what an `Agent` is
|
||||
2. what an `AgentLoop` is
|
||||
3. what the lifecycle of `AgentLoop` is
|
||||
4. what the event surface around `AgentLoop` is
|
||||
5. how persona / identity is assembled
|
||||
6. how capabilities are represented
|
||||
7. how context boundaries and compression work
|
||||
8. how subagent coordination works
|
||||
|
||||
These are the current working boundaries.
|
||||
|
||||
If they need to be adjusted, they should be adjusted explicitly rather than drift implicitly in code.
|
||||
|
||||
---
|
||||
|
||||
## Status of this directory
|
||||
|
||||
The documents here are working materials.
|
||||
|
||||
They are not final or immutable.
|
||||
|
||||
If current notes are incomplete, incorrectly split, or too broad, they should be revised. This directory should evolve with the refactor rather than pretending the first draft is complete.
|
||||
|
||||
---
|
||||
|
||||
## Suggested document split
|
||||
|
||||
This directory may eventually contain notes such as:
|
||||
|
||||
- `agent-overview.md`
|
||||
- what an Agent is
|
||||
- `agent-loop.md`
|
||||
- AgentLoop contract, lifecycle, event surface
|
||||
- `persona.md`
|
||||
- persona and identity assembly
|
||||
- `capability.md`
|
||||
- tools / skills / MCP capability semantics
|
||||
- `context.md`
|
||||
- context scope, history, summary, compression
|
||||
- `subagent.md`
|
||||
- subagent coordination rules
|
||||
|
||||
These files should be added only when they help clarify the current refactor work.
|
||||
|
||||
This directory should not turn into a generic architecture dump.
|
||||
|
||||
---
|
||||
|
||||
## What this directory is not for
|
||||
|
||||
This directory is not intended for:
|
||||
|
||||
- broad speculative architecture
|
||||
- future multi-node protocol design not required by the current refactor
|
||||
- parallel feature planning unrelated to Agent consolidation
|
||||
- adding new concepts before current ones are made clear
|
||||
|
||||
If a topic does not directly help reduce ambiguity in the current Agent model, it probably does not belong here yet.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to implementation
|
||||
|
||||
Implementation changes should not keep redefining Agent semantics implicitly.
|
||||
|
||||
If a PR changes or depends on Agent semantics, those semantics should either already exist here or be clarified in a linked issue first.
|
||||
|
||||
This directory is here to make implementation narrower and more disciplined.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to GitHub tracking
|
||||
|
||||
The umbrella issue for this refactor should point here.
|
||||
|
||||
The issue is the coordination surface.
|
||||
|
||||
This directory is the repository-local working surface.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The main question of this refactor is not:
|
||||
|
||||
- what more can Agent do
|
||||
|
||||
The main question is:
|
||||
|
||||
- what is the smallest stable model that current Agent behavior can be organized around
|
||||
164
docs/agent-refactor/context.md
Normal file
164
docs/agent-refactor/context.md
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
# Context
|
||||
|
||||
## What this document covers
|
||||
|
||||
This document makes explicit the boundaries of context management in the agent loop:
|
||||
|
||||
- what fills the context window and how space is divided
|
||||
- what is stored in session history vs. built at request time
|
||||
- when and how context compression happens
|
||||
- how token budgets are estimated
|
||||
|
||||
These are existing concepts. This document clarifies their boundaries rather than introducing new ones.
|
||||
|
||||
---
|
||||
|
||||
## Context window regions
|
||||
|
||||
The context window is the model's total input capacity. Four regions fill it:
|
||||
|
||||
| Region | Assembled by | Stored in session? |
|
||||
|---|---|---|
|
||||
| System prompt | `BuildMessages()` — static + dynamic parts | No |
|
||||
| Summary | `SetSummary()` stores it; `BuildMessages()` injects it | Separate from history |
|
||||
| Session history | User / assistant / tool messages | Yes |
|
||||
| Tool definitions | Provider adapter injects at call time | No |
|
||||
|
||||
`MaxTokens` (the output generation limit) must also be reserved from the total budget.
|
||||
|
||||
The available space for history is therefore:
|
||||
|
||||
```
|
||||
history_budget = ContextWindow - system_prompt - summary - tool_definitions - MaxTokens
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ContextWindow vs MaxTokens
|
||||
|
||||
These serve different purposes:
|
||||
|
||||
- **MaxTokens** — maximum tokens the LLM may generate in one response. Sent as the `max_tokens` request parameter.
|
||||
- **ContextWindow** — the model's total input context capacity.
|
||||
|
||||
These were previously set to the same value, which caused the summarization threshold to fire either far too early (at the default 32K) or not at all (when a user raised `max_tokens`).
|
||||
|
||||
Current default when not explicitly configured: `ContextWindow = MaxTokens * 4`.
|
||||
|
||||
---
|
||||
|
||||
## Session history
|
||||
|
||||
Session history stores only conversation messages:
|
||||
|
||||
- `user` — user input
|
||||
- `assistant` — LLM response (may include `ToolCalls`)
|
||||
- `tool` — tool execution results
|
||||
|
||||
Session history does **not** contain:
|
||||
|
||||
- System prompts — assembled at request time by `BuildMessages`
|
||||
- Summary content — stored separately via `SetSummary`, injected by `BuildMessages`
|
||||
|
||||
This distinction matters: any code that operates on session history — compression, boundary detection, token estimation — must not assume a system message is present.
|
||||
|
||||
---
|
||||
|
||||
## Turn
|
||||
|
||||
A **Turn** is one complete cycle:
|
||||
|
||||
> user message -> LLM iterations (possibly including tool calls) -> final assistant response
|
||||
|
||||
This definition comes from the agent loop design (#1316). In session history, Turn boundaries are identified by `user`-role messages.
|
||||
|
||||
Turn is the atomic unit for compression. Cutting inside a Turn can orphan tool-call sequences — an assistant message with `ToolCalls` separated from its corresponding `tool` results. Compressing at Turn boundaries avoids this by construction.
|
||||
|
||||
`parseTurnBoundaries(history)` returns the starting index of each Turn.
|
||||
`findSafeBoundary(history, targetIndex)` snaps a target cut point to the nearest Turn boundary.
|
||||
|
||||
---
|
||||
|
||||
## Compression paths
|
||||
|
||||
Three compression paths exist, in order of preference:
|
||||
|
||||
### 1. Async summarization
|
||||
|
||||
`maybeSummarize` runs after each Turn completes.
|
||||
|
||||
Triggers when message count exceeds a threshold, or when estimated history tokens exceed a percentage of `ContextWindow`. If triggered, a background goroutine calls the LLM to produce a summary of the oldest messages. The summary is stored via `SetSummary`; `BuildMessages` injects it into the system prompt on the next call.
|
||||
|
||||
Cut point uses `findSafeBoundary` so no Turn is split.
|
||||
|
||||
### 2. Proactive budget check
|
||||
|
||||
`isOverContextBudget` runs before each LLM call.
|
||||
|
||||
Uses the full budget formula: `message_tokens + tool_def_tokens + MaxTokens > ContextWindow`. If over budget, triggers `forceCompression` and rebuilds messages before calling the LLM.
|
||||
|
||||
This prevents wasted (and billed) LLM calls that would otherwise fail with a context-window error.
|
||||
|
||||
### 3. Emergency compression (reactive)
|
||||
|
||||
`forceCompression` runs when the LLM returns a context-window error despite the proactive check.
|
||||
|
||||
Drops the oldest ~50% of Turns. If the history is a single Turn with no safe split point (e.g. one user message followed by a massive tool response), falls back to keeping only the most recent user message — breaking Turn atomicity as a last resort to avoid a context-exceeded loop.
|
||||
|
||||
Stores a compression note in the session summary (not in history messages) so `BuildMessages` can include it in the next system prompt.
|
||||
|
||||
This is the fallback for when the token estimate undershoots reality.
|
||||
|
||||
---
|
||||
|
||||
## Token estimation
|
||||
|
||||
Estimation uses a heuristic of ~2.5 characters per token (`chars * 2 / 5`).
|
||||
|
||||
`estimateMessageTokens` counts:
|
||||
|
||||
- `Content` (rune count, for multibyte correctness)
|
||||
- `ReasoningContent` (extended thinking / chain-of-thought)
|
||||
- `ToolCalls` — ID, type, function name, arguments
|
||||
- `ToolCallID` (tool result metadata)
|
||||
- Per-message overhead (role label, JSON structure)
|
||||
- `Media` items — flat per-item token estimate, added directly to the final count (not through the character heuristic, since actual cost depends on resolution and provider-specific image tokenization)
|
||||
|
||||
`estimateToolDefsTokens` counts tool definition overhead: name, description, JSON schema of parameters.
|
||||
|
||||
These are deliberately heuristic. The proactive check handles the common case; the reactive path catches estimation errors.
|
||||
|
||||
---
|
||||
|
||||
## Interface boundaries
|
||||
|
||||
Context budget functions (`parseTurnBoundaries`, `findSafeBoundary`, `estimateMessageTokens`, `isOverContextBudget`) are **pure functions**. They take `[]providers.Message` and integer parameters. They have no dependency on `AgentLoop` or any other runtime struct.
|
||||
|
||||
`BuildMessages` is the sole assembler of the final message array sent to the LLM. Budget functions inform compression decisions but do not construct messages.
|
||||
|
||||
`forceCompression` and `summarizeSession` mutate session state (history and summary). `BuildMessages` reads that state to construct context. The flow is:
|
||||
|
||||
```
|
||||
budget check --> compression decision --> mutate session --> BuildMessages reads session --> LLM call
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known gaps
|
||||
|
||||
These are recognized limitations in the current implementation, documented here for visibility:
|
||||
|
||||
- **Summarization trigger does not use the full budget formula.** `maybeSummarize` compares estimated history tokens against a percentage of `ContextWindow`. It does not account for system prompt size, tool definition overhead, or `MaxTokens` reserve. The proactive check covers the critical path (preventing 400 errors), but the summarization trigger could be aligned with the same budget model for more accurate early compression.
|
||||
|
||||
- **Token estimation is heuristic.** It does not account for provider-specific tokenization, exact system prompt size (assembled separately), or variable image token costs. The two-path design (proactive + reactive) is intended to tolerate this imprecision.
|
||||
|
||||
- **Reactive retry does not preserve media.** When the reactive path rebuilds context after compression, it currently passes empty values for media references. This is a pre-existing issue in the main loop, not introduced by the budget system.
|
||||
|
||||
---
|
||||
|
||||
## What this document does not cover
|
||||
|
||||
- How `AGENT.md` frontmatter configures context parameters — that is part of the Agent definition work
|
||||
- How the context builder assembles context in the new architecture — that is upcoming work
|
||||
- How compression events surface through the event system — that is part of the event model (#1316)
|
||||
- Subagent context isolation — that is a separate track
|
||||
35
docs/channels/dingtalk/README.fr.md
Normal file
35
docs/channels/dingtalk/README.fr.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
> Retour au [README](../../../README.fr.md)
|
||||
|
||||
# DingTalk
|
||||
|
||||
DingTalk est la plateforme de communication d'entreprise d'Alibaba, très populaire dans les milieux professionnels chinois. Elle utilise un SDK de streaming pour maintenir des connexions persistantes.
|
||||
|
||||
## Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"enabled": true,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Champ | Type | Requis | Description |
|
||||
| ------------- | ------ | ------ | ---------------------------------------------------------------- |
|
||||
| enabled | bool | Oui | Activer ou non le canal DingTalk |
|
||||
| client_id | string | Oui | Client ID de l'application DingTalk |
|
||||
| client_secret | string | Oui | Client Secret de l'application DingTalk |
|
||||
| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs |
|
||||
|
||||
## Procédure de configuration
|
||||
|
||||
1. Rendez-vous sur la [plateforme ouverte DingTalk](https://open.dingtalk.com/)
|
||||
2. Créez une application interne d'entreprise
|
||||
3. Obtenez le Client ID et le Client Secret depuis les paramètres de l'application
|
||||
4. Configurez OAuth et les abonnements aux événements (si nécessaire)
|
||||
5. Renseignez le Client ID et le Client Secret dans le fichier de configuration
|
||||
35
docs/channels/dingtalk/README.ja.md
Normal file
35
docs/channels/dingtalk/README.ja.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
> [README](../../../README.ja.md) に戻る
|
||||
|
||||
# DingTalk
|
||||
|
||||
DingTalkはアリババの企業向けコミュニケーションプラットフォームで、中国のビジネス環境で広く利用されています。ストリーミング SDK を使用して持続的な接続を維持します。
|
||||
|
||||
## 設定
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"enabled": true,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| フィールド | 型 | 必須 | 説明 |
|
||||
| ------------- | ------ | ---- | -------------------------------------------- |
|
||||
| enabled | bool | はい | DingTalk チャンネルを有効にするかどうか |
|
||||
| client_id | string | はい | DingTalk アプリケーションの Client ID |
|
||||
| client_secret | string | はい | DingTalk アプリケーションの Client Secret |
|
||||
| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 |
|
||||
|
||||
## セットアップ手順
|
||||
|
||||
1. [DingTalk オープンプラットフォーム](https://open.dingtalk.com/) にアクセスする
|
||||
2. 企業内部アプリケーションを作成する
|
||||
3. アプリケーション設定から Client ID と Client Secret を取得する
|
||||
4. OAuth とイベントサブスクリプションを設定する(必要な場合)
|
||||
5. Client ID と Client Secret を設定ファイルに入力する
|
||||
35
docs/channels/dingtalk/README.md
Normal file
35
docs/channels/dingtalk/README.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
> Back to [README](../../../README.md)
|
||||
|
||||
# DingTalk
|
||||
|
||||
DingTalk is Alibaba's enterprise communication platform, widely used in Chinese workplaces. It uses a streaming SDK to maintain persistent connections.
|
||||
|
||||
## Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"enabled": true,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------- | ------ | -------- | -------------------------------------------------------- |
|
||||
| enabled | bool | Yes | Whether to enable the DingTalk channel |
|
||||
| client_id | string | Yes | Client ID of the DingTalk application |
|
||||
| client_secret | string | Yes | Client Secret of the DingTalk application |
|
||||
| allow_from | array | No | User ID whitelist; empty means all users are allowed |
|
||||
|
||||
## Setup
|
||||
|
||||
1. Go to the [DingTalk Open Platform](https://open.dingtalk.com/)
|
||||
2. Create an internal enterprise application
|
||||
3. Obtain the Client ID and Client Secret from the application settings
|
||||
4. Configure OAuth and event subscriptions (if needed)
|
||||
5. Fill in the Client ID and Client Secret in the configuration file
|
||||
35
docs/channels/dingtalk/README.pt-br.md
Normal file
35
docs/channels/dingtalk/README.pt-br.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
> Voltar ao [README](../../../README.pt-br.md)
|
||||
|
||||
# DingTalk
|
||||
|
||||
DingTalk é a plataforma de comunicação empresarial da Alibaba, amplamente utilizada no ambiente corporativo chinês. Ela usa um SDK de streaming para manter conexões persistentes.
|
||||
|
||||
## Configuração
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"enabled": true,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Campo | Tipo | Obrigatório | Descrição |
|
||||
| ------------- | ------ | ----------- | ---------------------------------------------------------------- |
|
||||
| enabled | bool | Sim | Se o canal DingTalk deve ser habilitado |
|
||||
| client_id | string | Sim | Client ID do aplicativo DingTalk |
|
||||
| client_secret | string | Sim | Client Secret do aplicativo DingTalk |
|
||||
| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos |
|
||||
|
||||
## Configuração passo a passo
|
||||
|
||||
1. Acesse a [Plataforma Aberta DingTalk](https://open.dingtalk.com/)
|
||||
2. Crie um aplicativo interno corporativo
|
||||
3. Obtenha o Client ID e o Client Secret nas configurações do aplicativo
|
||||
4. Configure OAuth e assinaturas de eventos (se necessário)
|
||||
5. Preencha o Client ID e o Client Secret no arquivo de configuração
|
||||
35
docs/channels/dingtalk/README.vi.md
Normal file
35
docs/channels/dingtalk/README.vi.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
> Quay lại [README](../../../README.vi.md)
|
||||
|
||||
# DingTalk
|
||||
|
||||
DingTalk là nền tảng giao tiếp doanh nghiệp của Alibaba, được sử dụng rộng rãi trong môi trường làm việc tại Trung Quốc. Nền tảng này sử dụng SDK streaming để duy trì kết nối liên tục.
|
||||
|
||||
## Cấu hình
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"enabled": true,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Trường | Kiểu | Bắt buộc | Mô tả |
|
||||
| ------------- | ------ | -------- | ---------------------------------------------------------------- |
|
||||
| enabled | bool | Có | Có bật kênh DingTalk hay không |
|
||||
| client_id | string | Có | Client ID của ứng dụng DingTalk |
|
||||
| client_secret | string | Có | Client Secret của ứng dụng DingTalk |
|
||||
| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả |
|
||||
|
||||
## Quy trình thiết lập
|
||||
|
||||
1. Truy cập [Nền tảng mở DingTalk](https://open.dingtalk.com/)
|
||||
2. Tạo một ứng dụng nội bộ doanh nghiệp
|
||||
3. Lấy Client ID và Client Secret từ cài đặt ứng dụng
|
||||
4. Cấu hình OAuth và đăng ký sự kiện (nếu cần)
|
||||
5. Điền Client ID và Client Secret vào file cấu hình
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
> 返回 [README](../../../README.zh.md)
|
||||
|
||||
# 钉钉
|
||||
|
||||
钉钉是阿里巴巴的企业通讯平台,在中国职场中广受欢迎。它采用流式 SDK 来维持持久连接。
|
||||
|
|
|
|||
39
docs/channels/discord/README.fr.md
Normal file
39
docs/channels/discord/README.fr.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
> Retour au [README](../../../README.fr.md)
|
||||
|
||||
# Discord
|
||||
|
||||
Discord est une application gratuite de chat vocal, vidéo et textuel conçue pour les communautés. PicoClaw se connecte aux serveurs Discord via l'API Bot Discord, avec prise en charge de la réception et de l'envoi de messages.
|
||||
|
||||
## Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"group_trigger": {
|
||||
"mention_only": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Champ | Type | Requis | Description |
|
||||
| ------------- | ------ | ------ | --------------------------------------------------------------------------- |
|
||||
| enabled | bool | Oui | Activer ou non le canal Discord |
|
||||
| token | string | Oui | Token du bot Discord |
|
||||
| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs |
|
||||
| group_trigger | object | Non | Paramètres de déclenchement de groupe (exemple : { "mention_only": false }) |
|
||||
|
||||
## Configuration initiale
|
||||
|
||||
1. Accéder au [Portail des développeurs Discord](https://discord.com/developers/applications) et créer une nouvelle application
|
||||
2. Activer les Intents :
|
||||
- Message Content Intent
|
||||
- Server Members Intent
|
||||
3. Obtenir le Token du bot
|
||||
4. Renseigner le Token du bot dans le fichier de configuration
|
||||
5. Inviter le bot sur le serveur et lui accorder les permissions nécessaires (ex. envoyer des messages, lire l'historique des messages)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue