Merge branch 'main' of https://github.com/sipeed/picoclaw into ssy
This commit is contained in:
commit
3167481595
512 changed files with 76557 additions and 7400 deletions
12
.env.example
12
.env.example
|
|
@ -5,16 +5,18 @@
|
||||||
# ANTHROPIC_API_KEY=sk-ant-xxx
|
# ANTHROPIC_API_KEY=sk-ant-xxx
|
||||||
# OPENAI_API_KEY=sk-xxx
|
# OPENAI_API_KEY=sk-xxx
|
||||||
# GEMINI_API_KEY=xxx
|
# GEMINI_API_KEY=xxx
|
||||||
# CEREBRAS_API_KEY=xxx
|
# MODELSCOPE_API_KEY=xxx
|
||||||
|
# CLAUDE_CODE_OAUTH=xxx
|
||||||
# ── Chat Channel ──────────────────────────
|
# ── Chat Channel ──────────────────────────
|
||||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||||
# DISCORD_BOT_TOKEN=xxx
|
# DISCORD_BOT_TOKEN=xxx
|
||||||
# LINE_CHANNEL_SECRET=xxx
|
# Feishu (飞书)
|
||||||
# LINE_CHANNEL_ACCESS_TOKEN=xxx
|
# PICOCLAW_CHANNELS_FEISHU_APP_ID=cli_xxx
|
||||||
|
# PICOCLAW_CHANNELS_FEISHU_APP_SECRET=xxx
|
||||||
|
# PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI=Typing,OneSecond
|
||||||
|
|
||||||
# ── Web Search (optional) ────────────────
|
# ── Web Search (optional) ────────────────
|
||||||
# BRAVE_SEARCH_API_KEY=BSA...
|
# BRAVE_SEARCH_API_KEY=BSA...
|
||||||
|
|
||||||
# ── Timezone ──────────────────────────────
|
# ── Timezone ──────────────────────────────
|
||||||
TZ=Asia/Tokyo
|
TZ=Asia/Shanghai
|
||||||
|
|
|
||||||
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@v4
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: docker.io
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create local tag for GoReleaser
|
||||||
|
run: git tag "${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
|
- name: Run GoReleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v6
|
||||||
|
with:
|
||||||
|
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[@]}"
|
||||||
|
|
||||||
19
.github/workflows/pr.yml
vendored
19
.github/workflows/pr.yml
vendored
|
|
@ -24,6 +24,25 @@ jobs:
|
||||||
with:
|
with:
|
||||||
version: v2.10.1
|
version: v2.10.1
|
||||||
|
|
||||||
|
vuln_check:
|
||||||
|
name: Security Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Run Govulncheck
|
||||||
|
uses: golang/govulncheck-action@v1
|
||||||
|
with:
|
||||||
|
go-package: ./...
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Tests
|
name: Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
27
.github/workflows/release.yml
vendored
27
.github/workflows/release.yml
vendored
|
|
@ -17,6 +17,11 @@ on:
|
||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
upload_tos:
|
||||||
|
description: "Upload to Volcengine TOS"
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-tag:
|
create-tag:
|
||||||
|
|
@ -60,6 +65,14 @@ jobs:
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
|
@ -91,6 +104,11 @@ jobs:
|
||||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||||
|
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
|
- name: Apply release flags
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
@ -100,3 +118,12 @@ jobs:
|
||||||
gh release edit "${{ inputs.tag }}" \
|
gh release edit "${{ inputs.tag }}" \
|
||||||
--draft=${{ inputs.draft }} \
|
--draft=${{ inputs.draft }} \
|
||||||
--prerelease=${{ inputs.prerelease }}
|
--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
|
||||||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -38,9 +38,21 @@ ralph/
|
||||||
.ralph/
|
.ralph/
|
||||||
tasks/
|
tasks/
|
||||||
|
|
||||||
|
# Plans
|
||||||
|
docs/plans/
|
||||||
|
|
||||||
# Editors
|
# Editors
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# Added by goreleaser init:
|
# Added by goreleaser init:
|
||||||
dist/
|
dist/
|
||||||
|
*.vite/
|
||||||
|
|
||||||
|
# Windows Application Icon/Resource
|
||||||
|
*.syso
|
||||||
|
|
||||||
|
# Keep embedded backend dist directory placeholder in VCS
|
||||||
|
!web/backend/dist/
|
||||||
|
web/backend/dist/*
|
||||||
|
!web/backend/dist/.gitkeep
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ linters:
|
||||||
- containedctx
|
- containedctx
|
||||||
- cyclop
|
- cyclop
|
||||||
- depguard
|
- depguard
|
||||||
- dupl
|
|
||||||
- dupword
|
- dupword
|
||||||
- err113
|
- err113
|
||||||
- exhaustruct
|
- exhaustruct
|
||||||
|
|
|
||||||
146
.goreleaser.yaml
146
.goreleaser.yaml
|
|
@ -5,7 +5,10 @@ version: 2
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
- go generate ./cmd/picoclaw
|
- 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 web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
|
|
@ -15,10 +18,48 @@ builds:
|
||||||
- stdjson
|
- stdjson
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w
|
- -s -w
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }}
|
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }}
|
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }}
|
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }}
|
- -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:
|
||||||
|
- stdjson
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
|
|
@ -30,27 +71,101 @@ builds:
|
||||||
- riscv64
|
- riscv64
|
||||||
- loong64
|
- loong64
|
||||||
- arm
|
- arm
|
||||||
main: ./cmd/picoclaw
|
- s390x
|
||||||
|
- mipsle
|
||||||
|
goarm:
|
||||||
|
- "6"
|
||||||
|
- "7"
|
||||||
|
gomips:
|
||||||
|
- softfloat
|
||||||
|
main: ./web/backend
|
||||||
|
ignore:
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
|
|
||||||
|
- id: picoclaw-launcher-tui
|
||||||
|
binary: picoclaw-launcher-tui
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
tags:
|
||||||
|
- stdjson
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
- darwin
|
||||||
|
- freebsd
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- riscv64
|
||||||
|
- loong64
|
||||||
|
- arm
|
||||||
|
- s390x
|
||||||
|
- mipsle
|
||||||
|
goarm:
|
||||||
|
- "6"
|
||||||
|
- "7"
|
||||||
|
gomips:
|
||||||
|
- softfloat
|
||||||
|
main: ./cmd/picoclaw-launcher-tui
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
|
||||||
dockers_v2:
|
dockers_v2:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
dockerfile: Dockerfile.goreleaser
|
dockerfile: docker/Dockerfile.goreleaser
|
||||||
|
extra_files:
|
||||||
|
- docker/entrypoint.sh
|
||||||
ids:
|
ids:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
images:
|
images:
|
||||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||||
- "docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}"
|
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
|
||||||
tags:
|
tags:
|
||||||
- "{{ .Tag }}"
|
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}'
|
||||||
- "latest"
|
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
|
||||||
platforms:
|
platforms:
|
||||||
- linux/amd64
|
- linux/amd64
|
||||||
- linux/arm64
|
- linux/arm64
|
||||||
- linux/riscv64
|
- 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:
|
archives:
|
||||||
- formats: [tar.gz]
|
- formats: [tar.gz]
|
||||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||||
|
|
@ -68,10 +183,13 @@ archives:
|
||||||
|
|
||||||
nfpms:
|
nfpms:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
|
ids:
|
||||||
|
- picoclaw
|
||||||
|
- picoclaw-launcher
|
||||||
|
- picoclaw-launcher-tui
|
||||||
package_name: picoclaw
|
package_name: picoclaw
|
||||||
file_name_template: >-
|
file_name_template: >-
|
||||||
{{ .PackageName }}_
|
{{ .PackageName }}_
|
||||||
{{- .Version }}_
|
|
||||||
{{- if eq .Arch "amd64" }}x86_64
|
{{- if eq .Arch "amd64" }}x86_64
|
||||||
{{- else if eq .Arch "arm64" }}aarch64
|
{{- else if eq .Arch "arm64" }}aarch64
|
||||||
{{- else if eq .Arch "arm" }}armv{{ .Arm }}
|
{{- else if eq .Arch "arm" }}armv{{ .Arm }}
|
||||||
|
|
@ -85,6 +203,11 @@ nfpms:
|
||||||
- rpm
|
- rpm
|
||||||
- deb
|
- deb
|
||||||
bindir: /usr/bin
|
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:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
|
|
@ -99,6 +222,7 @@ changelog:
|
||||||
# lzma: true
|
# lzma: true
|
||||||
|
|
||||||
release:
|
release:
|
||||||
|
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
|
||||||
footer: >-
|
footer: >-
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -269,8 +269,8 @@ Once your PR is submitted, you can reach out to the assigned reviewers listed in
|
||||||
|Function| Reviewer|
|
|Function| Reviewer|
|
||||||
|--- |--- |
|
|--- |--- |
|
||||||
|Provider|@yinwm |
|
|Provider|@yinwm |
|
||||||
|Channel |@yinwm |
|
|Channel |@yinwm/@alexhoshina |
|
||||||
|Agent |@lxowalle|
|
|Agent |@lxowalle/@Zhaoyikaiii|
|
||||||
|Tools |@lxowalle|
|
|Tools |@lxowalle|
|
||||||
|SKill ||
|
|SKill ||
|
||||||
|MCP ||
|
|MCP ||
|
||||||
|
|
|
||||||
|
|
@ -268,8 +268,8 @@ Release 分支的保护级别高于 `main`,在任何情况下均不允许直
|
||||||
|Function| Reviewer|
|
|Function| Reviewer|
|
||||||
|--- |--- |
|
|--- |--- |
|
||||||
|Provider|@yinwm |
|
|Provider|@yinwm |
|
||||||
|Channel |@yinwm |
|
|Channel |@yinwm/@alexhoshina |
|
||||||
|Agent |@lxowalle|
|
|Agent |@lxowalle/@Zhaoyikaiii|
|
||||||
|Tools |@lxowalle|
|
|Tools |@lxowalle|
|
||||||
|SKill ||
|
|SKill ||
|
||||||
|MCP ||
|
|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,
|
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
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
PicoClaw is heavily inspired by and based on [nanobot](https://github.com/HKUDS/nanobot) by HKUDS.
|
|
||||||
|
|
|
||||||
136
Makefile
136
Makefile
|
|
@ -11,13 +11,35 @@ 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")
|
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||||
BUILD_TIME=$(shell date +%FT%T%z)
|
BUILD_TIME=$(shell date +%FT%T%z)
|
||||||
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
||||||
INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal
|
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||||
LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w"
|
LDFLAGS=-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 variables
|
||||||
GO?=CGO_ENABLED=0 go
|
GO?=CGO_ENABLED=0 go
|
||||||
GOFLAGS?=-v -tags stdjson
|
GOFLAGS?=-v -tags stdjson
|
||||||
|
|
||||||
|
# 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?=golangci-lint
|
GOLANGCI_LINT?=golangci-lint
|
||||||
|
|
||||||
|
|
@ -44,10 +66,14 @@ ifeq ($(UNAME_S),Linux)
|
||||||
ARCH=amd64
|
ARCH=amd64
|
||||||
else ifeq ($(UNAME_M),aarch64)
|
else ifeq ($(UNAME_M),aarch64)
|
||||||
ARCH=arm64
|
ARCH=arm64
|
||||||
|
else ifeq ($(UNAME_M),armv81)
|
||||||
|
ARCH=arm64
|
||||||
else ifeq ($(UNAME_M),loongarch64)
|
else ifeq ($(UNAME_M),loongarch64)
|
||||||
ARCH=loong64
|
ARCH=loong64
|
||||||
else ifeq ($(UNAME_M),riscv64)
|
else ifeq ($(UNAME_M),riscv64)
|
||||||
ARCH=riscv64
|
ARCH=riscv64
|
||||||
|
else ifeq ($(UNAME_M),mipsel)
|
||||||
|
ARCH=mipsle
|
||||||
else
|
else
|
||||||
ARCH=$(UNAME_M)
|
ARCH=$(UNAME_M)
|
||||||
endif
|
endif
|
||||||
|
|
@ -85,16 +111,78 @@ build: generate
|
||||||
@echo "Build complete: $(BINARY_PATH)"
|
@echo "Build complete: $(BINARY_PATH)"
|
||||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
@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
|
||||||
|
@$(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=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native $(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 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)
|
||||||
|
@echo "Build complete"
|
||||||
|
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit)
|
||||||
|
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)
|
||||||
|
@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)
|
||||||
|
@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 $(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)"
|
||||||
|
|
||||||
## build-all: Build picoclaw for all platforms
|
## build-all: Build picoclaw for all platforms
|
||||||
build-all: generate
|
build-all: generate
|
||||||
@echo "Building for multiple platforms..."
|
@echo "Building for multiple platforms..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_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=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=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=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(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 $(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=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=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
|
GOOS=netbsd GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
|
||||||
|
GOOS=netbsd GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
|
||||||
@echo "All builds complete"
|
@echo "All builds complete"
|
||||||
|
|
||||||
## install: Install picoclaw to system and copy builtin skills
|
## install: Install picoclaw to system and copy builtin skills
|
||||||
|
|
@ -130,11 +218,11 @@ clean:
|
||||||
@echo "Clean complete"
|
@echo "Clean complete"
|
||||||
|
|
||||||
## vet: Run go vet for static analysis
|
## vet: Run go vet for static analysis
|
||||||
vet:
|
vet: generate
|
||||||
@$(GO) vet ./...
|
@$(GO) vet ./...
|
||||||
|
|
||||||
## test: Test Go code
|
## test: Test Go code
|
||||||
test:
|
test: generate
|
||||||
@$(GO) test ./...
|
@$(GO) test ./...
|
||||||
|
|
||||||
## fmt: Format Go code
|
## fmt: Format Go code
|
||||||
|
|
@ -166,6 +254,44 @@ check: deps fmt vet test
|
||||||
run: build
|
run: build
|
||||||
@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
|
@$(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
|
||||||
|
|
||||||
## help: Show this help message
|
## help: Show this help message
|
||||||
help:
|
help:
|
||||||
@echo "picoclaw Makefile"
|
@echo "picoclaw Makefile"
|
||||||
|
|
@ -181,6 +307,8 @@ help:
|
||||||
@echo " make install # Install to ~/.local/bin"
|
@echo " make install # Install to ~/.local/bin"
|
||||||
@echo " make uninstall # Remove from /usr/local/bin"
|
@echo " make uninstall # Remove from /usr/local/bin"
|
||||||
@echo " make install-skills # Install skills to workspace"
|
@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 ""
|
||||||
@echo "Environment Variables:"
|
@echo "Environment Variables:"
|
||||||
@echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)"
|
@echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)"
|
||||||
|
|
|
||||||
208
README.fr.md
208
README.fr.md
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1>
|
<h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1>
|
||||||
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<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://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
|
|
||||||
⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz.
|
⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz.
|
||||||
|
|
||||||
🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM et x86. Un clic et c'est parti !
|
🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM, MIPS et x86. Un clic et c'est parti !
|
||||||
|
|
||||||
🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle.
|
🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle.
|
||||||
|
|
||||||
|
|
@ -164,47 +164,49 @@ Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installe
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Configurez vos clés API
|
# 2. Premier lancement — génère docker/data/config.json puis s'arrête
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc.
|
# Le conteneur affiche "First-run setup complete." puis s'arrête.
|
||||||
|
|
||||||
# 3. Compiler & Démarrer
|
# 3. Configurez vos clés API
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # Clés API du fournisseur, tokens de bot, etc.
|
||||||
|
|
||||||
|
# 4. Démarrer
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`.
|
> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Voir les logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
# 4. Voir les logs
|
# 6. Arrêter
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
|
|
||||||
# 5. Arrêter
|
|
||||||
docker compose --profile gateway down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Mode Agent (exécution unique)
|
### Mode Agent (exécution unique)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Poser une question
|
# Poser une question
|
||||||
docker compose run --rm picoclaw-agent -m "Combien font 2+2 ?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Combien font 2+2 ?"
|
||||||
|
|
||||||
# Mode interactif
|
# Mode interactif
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Recompiler
|
### Mettre à jour
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Démarrage Rapide
|
### 🚀 Démarrage Rapide
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Configurez votre clé API dans `~/.picoclaw/config.json`.
|
> Configurez votre clé API dans `~/.picoclaw/config.json`. Obtenez des clés API : [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). La recherche web est optionnelle — obtenez gratuitement l'[API Tavily](https://tavily.com) (1000 requêtes gratuites/mois) ou l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois).
|
||||||
> Obtenir des clés API : [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> La recherche web est **optionnelle** — obtenez gratuitement l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois) ou utilisez le repli automatique intégré.
|
|
||||||
|
|
||||||
**1. Initialiser**
|
**1. Initialiser**
|
||||||
|
|
||||||
|
|
@ -218,15 +220,21 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model_name": "gpt4"
|
"model_name": "gpt-5.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
|
|
@ -252,6 +260,9 @@ picoclaw onboard
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Nouveau** : Le format de configuration `model_list` permet d'ajouter des fournisseurs sans modifier le code. Voir [Configuration de Modèle](#configuration-de-modèle-model_list) pour plus de détails.
|
||||||
|
> `request_timeout` est optionnel et s'exprime en secondes. S'il est omis ou défini à `<= 0`, PicoClaw utilise le délai d'expiration par défaut (120s).
|
||||||
|
|
||||||
**3. Obtenir des Clés API**
|
**3. Obtenir des Clés API**
|
||||||
|
|
||||||
* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
|
|
@ -280,7 +291,7 @@ Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom
|
||||||
| **QQ** | Facile (AppID + AppSecret) |
|
| **QQ** | Facile (AppID + AppSecret) |
|
||||||
| **DingTalk** | Moyen (identifiants de l'application) |
|
| **DingTalk** | Moyen (identifiants de l'application) |
|
||||||
| **LINE** | Moyen (identifiants + URL de webhook) |
|
| **LINE** | Moyen (identifiants + URL de webhook) |
|
||||||
| **WeCom** | Moyen (CorpID + configuration webhook) |
|
| **WeCom AI Bot** | Moyen (Token + clé AES) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recommandé)</summary>
|
<summary><b>Telegram</b> (Recommandé)</summary>
|
||||||
|
|
@ -448,8 +459,6 @@ picoclaw gateway
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"channel_secret": "VOTRE_CHANNEL_SECRET",
|
"channel_secret": "VOTRE_CHANNEL_SECRET",
|
||||||
"channel_access_token": "VOTRE_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "VOTRE_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18791,
|
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -462,12 +471,14 @@ picoclaw gateway
|
||||||
LINE exige HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel :
|
LINE exige HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Exemple avec ngrok
|
# Exemple avec ngrok (tunnel vers le serveur Gateway partagé)
|
||||||
ngrok http 18791
|
ngrok http 18790
|
||||||
```
|
```
|
||||||
|
|
||||||
Puis configurez l'URL du Webhook dans la LINE Developers Console sur `https://votre-domaine/webhook/line` et activez **Use webhook**.
|
Puis configurez l'URL du Webhook dans la LINE Developers Console sur `https://votre-domaine/webhook/line` et activez **Use webhook**.
|
||||||
|
|
||||||
|
> **Note** : Le webhook LINE est servi par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Si vous utilisez ngrok ou un proxy inverse, faites pointer le tunnel vers le port `18790`.
|
||||||
|
|
||||||
**4. Lancer**
|
**4. Lancer**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -476,19 +487,20 @@ picoclaw gateway
|
||||||
|
|
||||||
> Dans les discussions de groupe, le bot répond uniquement lorsqu'il est mentionné avec @. Les réponses citent le message original.
|
> Dans les discussions de groupe, le bot répond uniquement lorsqu'il est mentionné avec @. Les réponses citent le message original.
|
||||||
|
|
||||||
> **Docker Compose** : Ajoutez `ports: ["18791:18791"]` au service `picoclaw-gateway` pour exposer le port du webhook.
|
> **Docker Compose** : Si vous avez besoin d'exposer le webhook LINE via Docker, mappez le port du Gateway partagé (par défaut `18790`) vers l'hôte, par exemple `ports: ["18790:18790"]`. Notez que le serveur Gateway sert les webhooks de tous les canaux à partir de ce port.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>WeCom (WeChat Work)</b></summary>
|
<summary><b>WeCom (WeChat Work)</b></summary>
|
||||||
|
|
||||||
PicoClaw prend en charge deux types d'intégration WeCom :
|
PicoClaw prend en charge trois types d'intégration WeCom :
|
||||||
|
|
||||||
**Option 1 : WeCom Bot (Robot Intelligent)** - Configuration plus facile, prend en charge les discussions de groupe
|
**Option 1 : WeCom Bot (Robot)** - Configuration plus facile, prend en charge les discussions de groupe
|
||||||
**Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive
|
**Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive, chat privé uniquement
|
||||||
|
**Option 3 : WeCom AI Bot (Bot Intelligent)** - Bot IA officiel, réponses en streaming, prend en charge groupe et privé
|
||||||
|
|
||||||
Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour des instructions détaillées.
|
Voir le [Guide de Configuration WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) pour des instructions détaillées.
|
||||||
|
|
||||||
**Configuration Rapide - WeCom Bot :**
|
**Configuration Rapide - WeCom Bot :**
|
||||||
|
|
||||||
|
|
@ -507,8 +519,6 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18793,
|
|
||||||
"webhook_path": "/webhook/wecom",
|
"webhook_path": "/webhook/wecom",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -527,7 +537,7 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour
|
||||||
**2. Configurer la réception des messages**
|
**2. Configurer la réception des messages**
|
||||||
|
|
||||||
* Dans les détails de l'application, cliquez sur "Recevoir les Messages" → "Configurer l'API"
|
* Dans les détails de l'application, cliquez sur "Recevoir les Messages" → "Configurer l'API"
|
||||||
* Définissez l'URL sur `http://your-server:18792/webhook/wecom-app`
|
* Définissez l'URL sur `http://your-server:18790/webhook/wecom-app`
|
||||||
* Générez le **Token** et l'**EncodingAESKey**
|
* Générez le **Token** et l'**EncodingAESKey**
|
||||||
|
|
||||||
**3. Configurer**
|
**3. Configurer**
|
||||||
|
|
@ -542,8 +552,6 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour
|
||||||
"agent_id": 1000002,
|
"agent_id": 1000002,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
"webhook_path": "/webhook/wecom-app",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -557,7 +565,40 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note** : WeCom App nécessite l'ouverture du port 18792 pour les callbacks webhook. Utilisez un proxy inverse pour HTTPS en production.
|
> **Note** : Les callbacks webhook WeCom App sont servis par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Assurez-vous que le port `18790` est accessible ou utilisez un proxy inverse HTTPS en production.
|
||||||
|
|
||||||
|
**Configuration Rapide - WeCom AI Bot :**
|
||||||
|
|
||||||
|
**1. Créer un AI Bot**
|
||||||
|
|
||||||
|
* Accédez à la Console d'Administration WeCom → Gestion des Applications → AI Bot
|
||||||
|
* Configurez l'URL de callback : `http://your-server:18791/webhook/wecom-aibot`
|
||||||
|
* Copiez le **Token** et générez l'**EncodingAESKey**
|
||||||
|
|
||||||
|
**2. Configurer**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_aibot": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
"allow_from": [],
|
||||||
|
"welcome_message": "Bonjour ! Comment puis-je vous aider ?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Lancer**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note** : WeCom AI Bot utilise le protocole pull en streaming — pas de problème de timeout. Les tâches longues (>5,5 min) basculent automatiquement vers la livraison via `response_url`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -571,6 +612,31 @@ Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul mes
|
||||||
|
|
||||||
Fichier de configuration : `~/.picoclaw/config.json`
|
Fichier de configuration : `~/.picoclaw/config.json`
|
||||||
|
|
||||||
|
### Variables d'Environnement
|
||||||
|
|
||||||
|
Vous pouvez remplacer les chemins par défaut à l'aide de variables d'environnement. Ceci est utile pour les installations portables, les déploiements conteneurisés ou l'exécution de picoclaw en tant que service système. Ces variables sont indépendantes et contrôlent différents chemins.
|
||||||
|
|
||||||
|
| Variable | Description | Chemin par Défaut |
|
||||||
|
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||||
|
| `PICOCLAW_CONFIG` | Remplace le chemin du fichier de configuration. Cela indique directement à picoclaw quel `config.json` charger, en ignorant tous les autres emplacements. | `~/.picoclaw/config.json` |
|
||||||
|
| `PICOCLAW_HOME` | Remplace le répertoire racine des données picoclaw. Cela modifie l'emplacement par défaut du `workspace` et des autres répertoires de données. | `~/.picoclaw` |
|
||||||
|
|
||||||
|
**Exemples :**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Exécuter picoclaw en utilisant un fichier de configuration spécifique
|
||||||
|
# Le chemin du workspace sera lu à partir de ce fichier de configuration
|
||||||
|
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||||
|
|
||||||
|
# Exécuter picoclaw avec toutes ses données stockées dans /opt/picoclaw
|
||||||
|
# La configuration sera chargée à partir du fichier par défaut ~/.picoclaw/config.json
|
||||||
|
# Le workspace sera créé dans /opt/picoclaw/workspace
|
||||||
|
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
|
|
||||||
|
# Utiliser les deux pour une configuration entièrement personnalisée
|
||||||
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
### Structure du Workspace
|
### Structure du Workspace
|
||||||
|
|
||||||
PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) :
|
PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) :
|
||||||
|
|
@ -586,7 +652,6 @@ PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.
|
||||||
├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min)
|
├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min)
|
||||||
├── IDENTITY.md # Identité de l'Agent
|
├── IDENTITY.md # Identité de l'Agent
|
||||||
├── SOUL.md # Âme de l'Agent
|
├── SOUL.md # Âme de l'Agent
|
||||||
├── TOOLS.md # Description des outils
|
|
||||||
└── USER.md # Préférences utilisateur
|
└── USER.md # Préférences utilisateur
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -764,12 +829,13 @@ Le sous-agent a accès aux outils (message, web_search, etc.) et peut communique
|
||||||
### Fournisseurs
|
### Fournisseurs
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages vocaux Telegram seront automatiquement transcrits.
|
> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages audio de n'importe quel canal seront automatiquement transcrits au niveau de l'agent.
|
||||||
|
|
||||||
| Fournisseur | Utilisation | Obtenir une Clé API |
|
| Fournisseur | Utilisation | Obtenir une Clé API |
|
||||||
| ------------------------ | ---------------------------------------- | ------------------------------------------------------ |
|
| ------------------------ | ---------------------------------------- | ------------------------------------------------------ |
|
||||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
|
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
|
||||||
|
| `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||||
| `openrouter` (À tester) | LLM (recommandé, accès à tous les modèles) | [openrouter.ai](https://openrouter.ai) |
|
| `openrouter` (À tester) | LLM (recommandé, accès à tous les modèles) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `anthropic` (À tester) | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic` (À tester) | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai` (À tester) | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
| `openai` (À tester) | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||||
|
|
@ -915,8 +981,11 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) |
|
||||||
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
|
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obtenir Clé](https://www.byteplus.com/) |
|
||||||
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obtenir une clé](https://longcat.chat/platform) |
|
||||||
|
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Obtenir un Token](https://modelscope.cn/my/tokens) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
|
@ -926,8 +995,13 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -943,7 +1017,7 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -954,8 +1028,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VolcEngine (Doubao)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -979,6 +1062,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
||||||
```
|
```
|
||||||
> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
|
> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
|
||||||
|
|
||||||
|
**Proxy/API personnalisée**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Équilibrage de Charge
|
#### Équilibrage de Charge
|
||||||
|
|
||||||
Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :
|
Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :
|
||||||
|
|
@ -987,14 +1081,14 @@ Configurez plusieurs points de terminaison pour le même nom de modèle—PicoCl
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -1126,6 +1220,14 @@ Cela se produit lorsqu'une autre instance du bot est en cours d'exécution. Assu
|
||||||
| Service | Offre Gratuite | Cas d'Utilisation |
|
| Service | Offre Gratuite | Cas d'Utilisation |
|
||||||
| ---------------- | -------------------- | ------------------------------------- |
|
| ---------------- | -------------------- | ------------------------------------- |
|
||||||
| **OpenRouter** | 200K tokens/mois | Multiples modèles (Claude, GPT-4, etc.) |
|
| **OpenRouter** | 200K tokens/mois | Multiples modèles (Claude, GPT-4, etc.) |
|
||||||
| **Zhipu** | 200K tokens/mois | Idéal pour les utilisateurs chinois |
|
| **Volcengine CodingPlan** | 9,9¥/premier mois | Idéal pour les utilisateurs chinois, multiples modèles SOTA (Doubao, DeepSeek, etc.) |
|
||||||
|
| **Zhipu** | 200K tokens/mois | Convient aux utilisateurs chinois |
|
||||||
| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web |
|
| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web |
|
||||||
| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) |
|
| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) |
|
||||||
|
| **ModelScope** | 2000 requêtes/jour | Inférence gratuite (Qwen, GLM, DeepSeek, etc.) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
208
README.ja.md
208
README.ja.md
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
|
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
|
||||||
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
|
|
||||||
⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。
|
⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。
|
||||||
|
|
||||||
🌍 **真のポータビリティ**: RISC-V、ARM、x86 対応の単一バイナリ。ワンクリックで Go!
|
🌍 **真のポータビリティ**: RISC-V、ARM、MIPS、x86 対応の単一バイナリ。ワンクリックで Go!
|
||||||
|
|
||||||
🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。
|
🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。
|
||||||
|
|
||||||
|
|
@ -126,47 +126,49 @@ Docker Compose を使えば、ローカルにインストールせずに PicoCla
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. API キーを設定
|
# 2. 初回起動 — docker/data/config.json を自動生成して終了
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キーを設定
|
# コンテナが "First-run setup complete." を表示して停止します。
|
||||||
|
|
||||||
# 3. ビルドと起動
|
# 3. API キーを設定
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # プロバイダー API キー、Bot トークンなどを設定
|
||||||
|
|
||||||
|
# 4. 起動
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。
|
> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. ログ確認
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
# 4. ログ確認
|
# 6. 停止
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
|
|
||||||
# 5. 停止
|
|
||||||
docker compose --profile gateway down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent モード(ワンショット)
|
### Agent モード(ワンショット)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 質問を投げる
|
# 質問を投げる
|
||||||
docker compose run --rm picoclaw-agent -m "What is 2+2?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
|
||||||
|
|
||||||
# インタラクティブモード
|
# インタラクティブモード
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### リビルド
|
### アップデート
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 クイックスタート(ネイティブ)
|
### 🚀 クイックスタート(ネイティブ)
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> `~/.picoclaw/config.json` に API キーを設定してください。
|
> `~/.picoclaw/config.json` に API キーを設定してください。API キーの取得先: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。Web 検索は **任意** です — 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)。
|
||||||
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> Web 検索は **任意** です - 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
|
|
||||||
|
|
||||||
**1. 初期化**
|
**1. 初期化**
|
||||||
|
|
||||||
|
|
@ -180,15 +182,21 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model_name": "gpt4"
|
"model_name": "gpt-5.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
|
|
@ -221,6 +229,9 @@ picoclaw onboard
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **新機能**: `model_list` 形式により、プロバイダーをコード変更なしで追加できます。詳細は [モデル設定](#モデル設定-model_list) を参照してください。
|
||||||
|
> `request_timeout` は任意の秒単位設定です。省略または `<= 0` の場合、PicoClaw はデフォルトのタイムアウト(120秒)を使用します。
|
||||||
|
|
||||||
**3. API キーの取得**
|
**3. API キーの取得**
|
||||||
|
|
||||||
- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
|
|
@ -249,7 +260,7 @@ Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話でき
|
||||||
| **QQ** | 簡単(AppID + AppSecret) |
|
| **QQ** | 簡単(AppID + AppSecret) |
|
||||||
| **DingTalk** | 普通(アプリ認証情報) |
|
| **DingTalk** | 普通(アプリ認証情報) |
|
||||||
| **LINE** | 普通(認証情報 + Webhook URL) |
|
| **LINE** | 普通(認証情報 + Webhook URL) |
|
||||||
| **WeCom** | 普通(CorpID + Webhook設定) |
|
| **WeCom AI Bot** | 普通(Token + AES キー) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b>(推奨)</summary>
|
<summary><b>Telegram</b>(推奨)</summary>
|
||||||
|
|
@ -413,8 +424,6 @@ picoclaw gateway
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18791,
|
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -428,11 +437,13 @@ LINE の Webhook には HTTPS が必要です。リバースプロキシまた
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ngrok の例
|
# ngrok の例
|
||||||
ngrok http 18791
|
ngrok http 18790
|
||||||
```
|
```
|
||||||
|
|
||||||
LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。
|
LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。
|
||||||
|
|
||||||
|
> **注意**: LINE の Webhook は共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は Gateway のポートを公開するか、リバースプロキシを設定してください。
|
||||||
|
|
||||||
**4. 起動**
|
**4. 起動**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -441,19 +452,20 @@ picoclaw gateway
|
||||||
|
|
||||||
> グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。
|
> グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。
|
||||||
|
|
||||||
> **Docker Compose**: `picoclaw-gateway` サービスに `ports: ["18791:18791"]` を追加して Webhook ポートを公開してください。
|
> **Docker Compose**: Gateway HTTP サーバーは共有の `127.0.0.1:18790` で Webhook を提供します。ホストからアクセスするには `picoclaw-gateway` サービスに `ports: ["18790:18790"]` を追加してください。
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>WeCom (企業微信)</b></summary>
|
<summary><b>WeCom (企業微信)</b></summary>
|
||||||
|
|
||||||
PicoClaw は2種類の WeCom 統合をサポートしています:
|
PicoClaw は3種類の WeCom 統合をサポートしています:
|
||||||
|
|
||||||
**オプション1: WeCom Bot (智能ロボット)** - 簡単な設定、グループチャット対応
|
**オプション1: WeCom Bot (ロボット)** - 簡単な設定、グループチャット対応
|
||||||
**オプション2: WeCom App (自作アプリ)** - より多機能、アクティブメッセージング対応
|
**オプション2: WeCom App (カスタムアプリ)** - より多機能、アクティブメッセージング対応、プライベートチャットのみ
|
||||||
|
**オプション3: WeCom AI Bot (スマートボット)** - 公式 AI Bot、ストリーミング返信、グループ・プライベート両対応
|
||||||
|
|
||||||
詳細な設定手順は [WeCom App Configuration Guide](docs/wecom-app-configuration.md) を参照してください。
|
詳細な設定手順は [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) を参照してください。
|
||||||
|
|
||||||
**クイックセットアップ - WeCom Bot:**
|
**クイックセットアップ - WeCom Bot:**
|
||||||
|
|
||||||
|
|
@ -472,13 +484,13 @@ PicoClaw は2種類の WeCom 統合をサポートしています:
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18793,
|
|
||||||
"webhook_path": "/webhook/wecom",
|
"webhook_path": "/webhook/wecom",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> **注意**: WeCom Bot の Webhook 受信は共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は Gateway のポートを公開するか、HTTPS 用のリバースプロキシを設定してください。
|
||||||
```
|
```
|
||||||
|
|
||||||
**クイックセットアップ - WeCom App:**
|
**クイックセットアップ - WeCom App:**
|
||||||
|
|
@ -492,7 +504,7 @@ PicoClaw は2種類の WeCom 統合をサポートしています:
|
||||||
**2. メッセージ受信を設定**
|
**2. メッセージ受信を設定**
|
||||||
|
|
||||||
* アプリ詳細で "メッセージを受信" → "APIを設定" をクリック
|
* アプリ詳細で "メッセージを受信" → "APIを設定" をクリック
|
||||||
* URL を `http://your-server:18792/webhook/wecom-app` に設定
|
* URL を `http://your-server:18790/webhook/wecom-app` に設定
|
||||||
* **Token** と **EncodingAESKey** を生成
|
* **Token** と **EncodingAESKey** を生成
|
||||||
|
|
||||||
**3. 設定**
|
**3. 設定**
|
||||||
|
|
@ -507,8 +519,6 @@ PicoClaw は2種類の WeCom 統合をサポートしています:
|
||||||
"agent_id": 1000002,
|
"agent_id": 1000002,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
"webhook_path": "/webhook/wecom-app",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -522,7 +532,40 @@ PicoClaw は2種類の WeCom 統合をサポートしています:
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
> **注意**: WeCom App は Webhook コールバック用にポート 18792 を開放する必要があります。本番環境では HTTPS 用のリバースプロキシを使用してください。
|
> **注意**: WeCom App の Webhook コールバックは共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は HTTPS 用のリバースプロキシを設定してください。
|
||||||
|
|
||||||
|
**クイックセットアップ - WeCom AI Bot:**
|
||||||
|
|
||||||
|
**1. AI Bot を作成**
|
||||||
|
|
||||||
|
* WeCom 管理コンソール → アプリ管理 → AI Bot
|
||||||
|
* コールバック URL を設定: `http://your-server:18791/webhook/wecom-aibot`
|
||||||
|
* **Token** をコピーし、**EncodingAESKey** を生成
|
||||||
|
|
||||||
|
**2. 設定**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_aibot": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
"allow_from": [],
|
||||||
|
"welcome_message": "こんにちは!何かお手伝いできますか?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 起動**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**: WeCom AI Bot はストリーミングプルプロトコルを使用 — 返信タイムアウトの心配なし。長時間タスク(>30秒)は自動的に `response_url` によるプッシュ配信に切り替わります。
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -530,6 +573,31 @@ picoclaw gateway
|
||||||
|
|
||||||
設定ファイル: `~/.picoclaw/config.json`
|
設定ファイル: `~/.picoclaw/config.json`
|
||||||
|
|
||||||
|
### 環境変数
|
||||||
|
|
||||||
|
環境変数を使用してデフォルトのパスを上書きできます。これは、ポータブルインストール、コンテナ化されたデプロイメント、または picoclaw をシステムサービスとして実行する場合に便利です。これらの変数は独立しており、異なるパスを制御します。
|
||||||
|
|
||||||
|
| 変数 | 説明 | デフォルトパス |
|
||||||
|
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||||
|
| `PICOCLAW_CONFIG` | 設定ファイルへのパスを上書きします。これにより、picoclaw は他のすべての場所を無視して、指定された `config.json` をロードします。 | `~/.picoclaw/config.json` |
|
||||||
|
| `PICOCLAW_HOME` | picoclaw データのルートディレクトリを上書きします。これにより、`workspace` やその他のデータディレクトリのデフォルトの場所が変更されます。 | `~/.picoclaw` |
|
||||||
|
|
||||||
|
**例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 特定の設定ファイルを使用して picoclaw を実行する
|
||||||
|
# ワークスペースのパスはその設定ファイル内から読み込まれます
|
||||||
|
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||||
|
|
||||||
|
# すべてのデータを /opt/picoclaw に保存して picoclaw を実行する
|
||||||
|
# 設定はデフォルトの ~/.picoclaw/config.json からロードされます
|
||||||
|
# ワークスペースは /opt/picoclaw/workspace に作成されます
|
||||||
|
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
|
|
||||||
|
# 両方を使用して完全にカスタマイズされたセットアップを行う
|
||||||
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
### ワークスペース構成
|
### ワークスペース構成
|
||||||
|
|
||||||
PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します:
|
PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します:
|
||||||
|
|
@ -545,7 +613,6 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw
|
||||||
├── HEARTBEAT.md # 定期タスクプロンプト(30分ごとに確認)
|
├── HEARTBEAT.md # 定期タスクプロンプト(30分ごとに確認)
|
||||||
├── IDENTITY.md # エージェントのアイデンティティ
|
├── IDENTITY.md # エージェントのアイデンティティ
|
||||||
├── SOUL.md # エージェントのソウル
|
├── SOUL.md # エージェントのソウル
|
||||||
├── TOOLS.md # ツールの説明
|
|
||||||
└── USER.md # ユーザー設定
|
└── USER.md # ユーザー設定
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -720,12 +787,13 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
### プロバイダー
|
### プロバイダー
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、Telegram の音声メッセージが自動的に文字起こしされます。
|
> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、あらゆるチャンネルからの音声メッセージがエージェントレベルで自動的に文字起こしされます。
|
||||||
|
|
||||||
| プロバイダー | 用途 | API キー取得先 |
|
| プロバイダー | 用途 | API キー取得先 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `gemini` | LLM(Gemini 直接) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM(Gemini 直接) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM(Zhipu 直接) | [bigmodel.cn](https://bigmodel.cn) |
|
| `zhipu` | LLM(Zhipu 直接) | [bigmodel.cn](https://bigmodel.cn) |
|
||||||
|
| `volcengine` | LLM(Volcengine 直接) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||||
| `openrouter`(要テスト) | LLM(推奨、全モデルにアクセス可能) | [openrouter.ai](https://openrouter.ai) |
|
| `openrouter`(要テスト) | LLM(推奨、全モデルにアクセス可能) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `anthropic`(要テスト) | LLM(Claude 直接) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic`(要テスト) | LLM(Claude 直接) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai`(要テスト) | LLM(GPT 直接) | [platform.openai.com](https://platform.openai.com) |
|
| `openai`(要テスト) | LLM(GPT 直接) | [platform.openai.com](https://platform.openai.com) |
|
||||||
|
|
@ -854,8 +922,11 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
|
||||||
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
|
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [キーを取得](https://www.byteplus.com) |
|
||||||
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [キーを取得](https://longcat.chat/platform) |
|
||||||
|
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [トークンを取得](https://modelscope.cn/my/tokens) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
|
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
|
@ -865,8 +936,13 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -882,7 +958,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -893,8 +969,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VolcEngine (Doubao)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -918,6 +1003,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
```
|
```
|
||||||
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
|
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
|
||||||
|
|
||||||
|
**カスタムプロキシ/API**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### ロードバランシング
|
#### ロードバランシング
|
||||||
|
|
||||||
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
|
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
|
||||||
|
|
@ -926,14 +1022,14 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -1044,9 +1140,17 @@ Web 検索を有効にするには:
|
||||||
| サービス | 無料枠 | ユースケース |
|
| サービス | 無料枠 | ユースケース |
|
||||||
|---------|--------|------------|
|
|---------|--------|------------|
|
||||||
| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) |
|
| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) |
|
||||||
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
| **Volcengine CodingPlan** | 9.9元/初月 | 中国ユーザーに最適、複数のSOTAモデル(Doubao、DeepSeek等) |
|
||||||
|
| **Zhipu** | 月 200K トークン | 中国ユーザーに適している |
|
||||||
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
|
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
|
||||||
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
||||||
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
|
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
|
||||||
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
||||||
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
||||||
|
| **ModelScope** | 1 日 2000 リクエスト | 無料推論(Qwen, GLM, DeepSeek など) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
535
README.md
535
README.md
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
|
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
|
||||||
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<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://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -54,9 +54,9 @@
|
||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/ROADMAP.md) —we can’t wait to have you on board!
|
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](ROADMAP.md) —we can’t wait to have you on board!
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
||||||
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
|
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
|
||||||
|
|
||||||
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
|
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
|
|
||||||
⚡️ **Lightning Fast**: 400X Faster startup time, boot in 1 second even in 0.6GHz single core.
|
⚡️ **Lightning Fast**: 400X Faster startup time, boot in 1 second even in 0.6GHz single core.
|
||||||
|
|
||||||
🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, and x86, One-click to Go!
|
🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, MIPS, and x86, One-click to Go!
|
||||||
|
|
||||||
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
|
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
|
||||||
|
|
||||||
|
|
@ -154,10 +154,15 @@ make build
|
||||||
# Build for multiple platforms
|
# Build for multiple platforms
|
||||||
make build-all
|
make build-all
|
||||||
|
|
||||||
|
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||||
|
make build-pi-zero
|
||||||
|
|
||||||
# Build And Install
|
# Build And Install
|
||||||
make install
|
make install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm` (output: `build/picoclaw-linux-arm`); 64-bit → `make build-linux-arm64` (output: `build/picoclaw-linux-arm64`). Or run `make build-pi-zero` to build both.
|
||||||
|
|
||||||
## 🐳 Docker Compose
|
## 🐳 Docker Compose
|
||||||
|
|
||||||
You can also run PicoClaw using Docker Compose without installing anything locally.
|
You can also run PicoClaw using Docker Compose without installing anything locally.
|
||||||
|
|
@ -167,47 +172,62 @@ You can also run PicoClaw using Docker Compose without installing anything local
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Set your API keys
|
# 2. First run — auto-generates docker/data/config.json then exits
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc.
|
# The container prints "First-run setup complete." and stops.
|
||||||
|
|
||||||
# 3. Build & Start
|
# 3. Set your API keys
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # Set provider API keys, bot tokens, etc.
|
||||||
|
|
||||||
|
# 4. Start
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
|
> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Check logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
# 4. Check logs
|
# 6. Stop
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
|
|
||||||
# 5. Stop
|
|
||||||
docker compose --profile gateway down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Launcher Mode (Web Console)
|
||||||
|
|
||||||
|
The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> The web console does not yet support authentication. Avoid exposing it to the public internet.
|
||||||
|
|
||||||
### Agent Mode (One-shot)
|
### Agent Mode (One-shot)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ask a question
|
# Ask a question
|
||||||
docker compose run --rm picoclaw-agent -m "What is 2+2?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
|
||||||
|
|
||||||
# Interactive mode
|
# Interactive mode
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rebuild
|
### Update
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Quick Start
|
### 🚀 Quick Start
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Set your API key in `~/.picoclaw/config.json`.
|
> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month).
|
||||||
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
|
||||||
|
|
||||||
**1. Initialize**
|
**1. Initialize**
|
||||||
|
|
||||||
|
|
@ -222,7 +242,7 @@ picoclaw onboard
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt-5.4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20
|
||||||
|
|
@ -230,9 +250,15 @@ picoclaw onboard
|
||||||
},
|
},
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "your-api-key"
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "your-api-key",
|
||||||
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
|
@ -255,6 +281,16 @@ picoclaw onboard
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_PERPLEXITY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://your-searxng-instance:8888",
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -262,11 +298,17 @@ picoclaw onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
|
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
|
||||||
|
> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).
|
||||||
|
|
||||||
**3. Get API Keys**
|
**3. Get API Keys**
|
||||||
|
|
||||||
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
* **Web Search** (optional): [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) · [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
* **Web Search** (optional):
|
||||||
|
* [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month)
|
||||||
|
* [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface
|
||||||
|
* [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed)
|
||||||
|
* [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month)
|
||||||
|
* DuckDuckGo - Built-in fallback (no API key required)
|
||||||
|
|
||||||
> **Note**: See `config.example.json` for a complete configuration template.
|
> **Note**: See `config.example.json` for a complete configuration template.
|
||||||
|
|
||||||
|
|
@ -282,16 +324,20 @@ That's it! You have a working AI assistant in 2 minutes.
|
||||||
|
|
||||||
## 💬 Chat Apps
|
## 💬 Chat Apps
|
||||||
|
|
||||||
Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
|
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom
|
||||||
|
|
||||||
|
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
|
||||||
|
|
||||||
| Channel | Setup |
|
| Channel | Setup |
|
||||||
| ------------ | ---------------------------------- |
|
| ------------ | ---------------------------------- |
|
||||||
| **Telegram** | Easy (just a token) |
|
| **Telegram** | Easy (just a token) |
|
||||||
| **Discord** | Easy (bot token + intents) |
|
| **Discord** | Easy (bot token + intents) |
|
||||||
|
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
|
||||||
|
| **Matrix** | Medium (homeserver + bot access token) |
|
||||||
| **QQ** | Easy (AppID + AppSecret) |
|
| **QQ** | Easy (AppID + AppSecret) |
|
||||||
| **DingTalk** | Medium (app credentials) |
|
| **DingTalk** | Medium (app credentials) |
|
||||||
| **LINE** | Medium (credentials + webhook URL) |
|
| **LINE** | Medium (credentials + webhook URL) |
|
||||||
| **WeCom** | Medium (CorpID + webhook setup) |
|
| **WeCom AI Bot** | Medium (Token + AES key) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recommended)</summary>
|
<summary><b>Telegram</b> (Recommended)</summary>
|
||||||
|
|
@ -324,6 +370,13 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**4. Telegram command menu (auto-registered at startup)**
|
||||||
|
|
||||||
|
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.
|
||||||
|
Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
|
||||||
|
|
||||||
|
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
@ -352,8 +405,7 @@ picoclaw gateway
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"]
|
||||||
"mention_only": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -366,9 +418,31 @@ picoclaw gateway
|
||||||
* Bot Permissions: `Send Messages`, `Read Message History`
|
* Bot Permissions: `Send Messages`, `Read Message History`
|
||||||
* Open the generated invite URL and add the bot to your server
|
* Open the generated invite URL and add the bot to your server
|
||||||
|
|
||||||
**Optional: Mention-only mode**
|
**Optional: Group trigger mode**
|
||||||
|
|
||||||
Set `"mention_only": true` to make the bot respond only when @-mentioned. Useful for shared servers where you want the bot to respond only when explicitly called.
|
By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"discord": {
|
||||||
|
"group_trigger": { "mention_only": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also trigger by keyword prefixes (e.g. `!bot`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"discord": {
|
||||||
|
"group_trigger": { "prefixes": ["!bot"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**6. Run**
|
**6. Run**
|
||||||
|
|
||||||
|
|
@ -378,6 +452,33 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>WhatsApp</b> (native via whatsmeow)</summary>
|
||||||
|
|
||||||
|
PicoClaw can connect to WhatsApp in two ways:
|
||||||
|
|
||||||
|
- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`).
|
||||||
|
- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false.
|
||||||
|
|
||||||
|
**Configure (native)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"whatsapp": {
|
||||||
|
"enabled": true,
|
||||||
|
"use_native": true,
|
||||||
|
"session_store_path": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `session_store_path` is empty, the session is stored in `<workspace>/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>QQ</b></summary>
|
<summary><b>QQ</b></summary>
|
||||||
|
|
||||||
|
|
@ -444,6 +545,40 @@ picoclaw gateway
|
||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Matrix</b></summary>
|
||||||
|
|
||||||
|
**1. Prepare bot account**
|
||||||
|
|
||||||
|
* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted)
|
||||||
|
* Create a bot user and obtain its access token
|
||||||
|
|
||||||
|
**2. Configure**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"matrix": {
|
||||||
|
"enabled": true,
|
||||||
|
"homeserver": "https://matrix.org",
|
||||||
|
"user_id": "@your-bot:matrix.org",
|
||||||
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>LINE</b></summary>
|
<summary><b>LINE</b></summary>
|
||||||
|
|
||||||
|
|
@ -462,8 +597,6 @@ picoclaw gateway
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18791,
|
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -471,13 +604,15 @@ picoclaw gateway
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
|
||||||
|
|
||||||
**3. Set up Webhook URL**
|
**3. Set up Webhook URL**
|
||||||
|
|
||||||
LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:
|
LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Example with ngrok
|
# Example with ngrok (gateway default port is 18790)
|
||||||
ngrok http 18791
|
ngrok http 18790
|
||||||
```
|
```
|
||||||
|
|
||||||
Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.
|
Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.
|
||||||
|
|
@ -490,19 +625,18 @@ picoclaw gateway
|
||||||
|
|
||||||
> In group chats, the bot responds only when @mentioned. Replies quote the original message.
|
> In group chats, the bot responds only when @mentioned. Replies quote the original message.
|
||||||
|
|
||||||
> **Docker Compose**: Add `ports: ["18791:18791"]` to the `picoclaw-gateway` service to expose the webhook port.
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>WeCom (企业微信)</b></summary>
|
<summary><b>WeCom (企业微信)</b></summary>
|
||||||
|
|
||||||
PicoClaw supports two types of WeCom integration:
|
PicoClaw supports three types of WeCom integration:
|
||||||
|
|
||||||
**Option 1: WeCom Bot (智能机器人)** - Easier setup, supports group chats
|
**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats
|
||||||
**Option 2: WeCom App (自建应用)** - More features, proactive messaging
|
**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only
|
||||||
|
**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat
|
||||||
|
|
||||||
See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detailed setup instructions.
|
See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions.
|
||||||
|
|
||||||
**Quick Setup - WeCom Bot:**
|
**Quick Setup - WeCom Bot:**
|
||||||
|
|
||||||
|
|
@ -521,8 +655,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18793,
|
|
||||||
"webhook_path": "/webhook/wecom",
|
"webhook_path": "/webhook/wecom",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -530,6 +662,8 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> WeCom webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
|
||||||
|
|
||||||
**Quick Setup - WeCom App:**
|
**Quick Setup - WeCom App:**
|
||||||
|
|
||||||
**1. Create an app**
|
**1. Create an app**
|
||||||
|
|
@ -537,10 +671,11 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
||||||
* Go to WeCom Admin Console → App Management → Create App
|
* Go to WeCom Admin Console → App Management → Create App
|
||||||
* Copy **AgentId** and **Secret**
|
* Copy **AgentId** and **Secret**
|
||||||
* Go to "My Company" page, copy **CorpID**
|
* Go to "My Company" page, copy **CorpID**
|
||||||
|
|
||||||
**2. Configure receive message**
|
**2. Configure receive message**
|
||||||
|
|
||||||
* In App details, click "Receive Message" → "Set API"
|
* In App details, click "Receive Message" → "Set API"
|
||||||
* Set URL to `http://your-server:18792/webhook/wecom-app`
|
* Set URL to `http://your-server:18790/webhook/wecom-app`
|
||||||
* Generate **Token** and **EncodingAESKey**
|
* Generate **Token** and **EncodingAESKey**
|
||||||
|
|
||||||
**3. Configure**
|
**3. Configure**
|
||||||
|
|
@ -555,8 +690,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
||||||
"agent_id": 1000002,
|
"agent_id": 1000002,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
"webhook_path": "/webhook/wecom-app",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -570,7 +703,40 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note**: WeCom App requires opening port 18792 for webhook callbacks. Use a reverse proxy for HTTPS.
|
> **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS.
|
||||||
|
|
||||||
|
**Quick Setup - WeCom AI Bot:**
|
||||||
|
|
||||||
|
**1. Create an AI Bot**
|
||||||
|
|
||||||
|
* Go to WeCom Admin Console → App Management → AI Bot
|
||||||
|
* In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot`
|
||||||
|
* Copy **Token** and click "Random Generate" for **EncodingAESKey**
|
||||||
|
|
||||||
|
**2. Configure**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_aibot": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
"allow_from": [],
|
||||||
|
"welcome_message": "Hello! How can I help you?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -584,6 +750,31 @@ Connect Picoclaw to the Agent Social Network simply by sending a single message
|
||||||
|
|
||||||
Config file: `~/.picoclaw/config.json`
|
Config file: `~/.picoclaw/config.json`
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
You can override default paths using environment variables. This is useful for portable installations, containerized deployments, or running picoclaw as a system service. These variables are independent and control different paths.
|
||||||
|
|
||||||
|
| Variable | Description | Default Path |
|
||||||
|
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||||
|
| `PICOCLAW_CONFIG` | Overrides the path to the configuration file. This directly tells picoclaw which `config.json` to load, ignoring all other locations. | `~/.picoclaw/config.json` |
|
||||||
|
| `PICOCLAW_HOME` | Overrides the root directory for picoclaw data. This changes the default location of the `workspace` and other data directories. | `~/.picoclaw` |
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run picoclaw using a specific config file
|
||||||
|
# The workspace path will be read from within that config file
|
||||||
|
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||||
|
|
||||||
|
# Run picoclaw with all its data stored in /opt/picoclaw
|
||||||
|
# Config will be loaded from the default ~/.picoclaw/config.json
|
||||||
|
# Workspace will be created at /opt/picoclaw/workspace
|
||||||
|
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
|
|
||||||
|
# Use both for a fully customized setup
|
||||||
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
### Workspace Layout
|
### Workspace Layout
|
||||||
|
|
||||||
PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`):
|
PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`):
|
||||||
|
|
@ -599,10 +790,29 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
|
||||||
├── HEARTBEAT.md # Periodic task prompts (checked every 30 min)
|
├── HEARTBEAT.md # Periodic task prompts (checked every 30 min)
|
||||||
├── IDENTITY.md # Agent identity
|
├── IDENTITY.md # Agent identity
|
||||||
├── SOUL.md # Agent soul
|
├── SOUL.md # Agent soul
|
||||||
├── TOOLS.md # Tool descriptions
|
|
||||||
└── USER.md # User preferences
|
└── USER.md # User preferences
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Skill Sources
|
||||||
|
|
||||||
|
By default, skills are loaded from:
|
||||||
|
|
||||||
|
1. `~/.picoclaw/workspace/skills` (workspace)
|
||||||
|
2. `~/.picoclaw/skills` (global)
|
||||||
|
3. `<current-working-directory>/skills` (builtin)
|
||||||
|
|
||||||
|
For advanced/test setups, you can override the builtin skills root with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unified Command Execution Policy
|
||||||
|
|
||||||
|
- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.
|
||||||
|
- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup.
|
||||||
|
- Unknown slash command (for example `/foo`) passes through to normal LLM processing.
|
||||||
|
- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing.
|
||||||
### 🔒 Security Sandbox
|
### 🔒 Security Sandbox
|
||||||
|
|
||||||
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
||||||
|
|
@ -779,19 +989,21 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
||||||
### Providers
|
### Providers
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level.
|
||||||
|
|
||||||
| Provider | Purpose | Get API Key |
|
| Provider | Purpose | Get API Key |
|
||||||
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
|
| ------------ | --------------------------------------- | ------------------------------------------------------------ |
|
||||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
|
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
|
||||||
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
| `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||||
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||||
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||||
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||||
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
|
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||||
|
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
|
||||||
|
| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) |
|
||||||
|
|
||||||
### Model Configuration (model_list)
|
### Model Configuration (model_list)
|
||||||
|
|
||||||
|
|
@ -807,7 +1019,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
#### 📋 All Supported Vendors
|
#### 📋 All Supported Vendors
|
||||||
|
|
||||||
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
||||||
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
|
| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
|
||||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
|
|
@ -819,10 +1031,15 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
||||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
||||||
|
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
||||||
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
|
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) |
|
||||||
|
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
|
||||||
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
|
||||||
|
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
|
@ -832,8 +1049,13 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -849,7 +1071,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -861,8 +1083,18 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VolcEngine (Doubao)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -899,6 +1131,26 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
|
|
||||||
> Run `picoclaw auth login --provider anthropic` to paste your API token.
|
> Run `picoclaw auth login --provider anthropic` to paste your API token.
|
||||||
|
|
||||||
|
**Anthropic Messages API (native format)**
|
||||||
|
|
||||||
|
For direct Anthropic API access or custom endpoints that only support Anthropic's native message format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use `anthropic-messages` protocol when:
|
||||||
|
> - Using third-party proxies that only support Anthropic's native `/v1/messages` endpoint (not OpenAI-compatible `/v1/chat/completions`)
|
||||||
|
> - Connecting to services like MiniMax, Synthetic that require Anthropic's native message format
|
||||||
|
> - The existing `anthropic` protocol returns 404 errors (indicating the endpoint doesn't support OpenAI-compatible format)
|
||||||
|
>
|
||||||
|
> **Note:** The `anthropic` protocol uses OpenAI-compatible format (`/v1/chat/completions`), while `anthropic-messages` uses Anthropic's native format (`/v1/messages`). Choose based on your endpoint's supported format.
|
||||||
|
|
||||||
**Ollama (local)**
|
**Ollama (local)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -915,10 +1167,24 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**LiteLLM Proxy**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "lite-gpt4",
|
||||||
|
"model": "litellm/lite-gpt4",
|
||||||
|
"api_base": "http://localhost:4000/v1",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
PicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.
|
||||||
|
|
||||||
#### Load Balancing
|
#### Load Balancing
|
||||||
|
|
||||||
Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them:
|
Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them:
|
||||||
|
|
@ -927,14 +1193,14 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -1043,6 +1309,10 @@ picoclaw agent -m "Hello"
|
||||||
"model": "anthropic/claude-opus-4-5"
|
"model": "anthropic/claude-opus-4-5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"session": {
|
||||||
|
"dm_scope": "per-channel-peer",
|
||||||
|
"backlog_limit": 20
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"openrouter": {
|
"openrouter": {
|
||||||
"api_key": "sk-or-v1-xxx"
|
"api_key": "sk-or-v1-xxx"
|
||||||
|
|
@ -1063,7 +1333,11 @@ picoclaw agent -m "Hello"
|
||||||
"allow_from": [""]
|
"allow_from": [""]
|
||||||
},
|
},
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
"enabled": false
|
"enabled": false,
|
||||||
|
"bridge_url": "ws://localhost:3001",
|
||||||
|
"use_native": false,
|
||||||
|
"session_store_path": "",
|
||||||
|
"allow_from": []
|
||||||
},
|
},
|
||||||
"feishu": {
|
"feishu": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
@ -1090,6 +1364,16 @@ picoclaw agent -m "Hello"
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://localhost:8888",
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
|
|
@ -1143,14 +1427,73 @@ discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
### Web search says "API 配置问题"
|
### Web search says "API key configuration issue"
|
||||||
|
|
||||||
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
|
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
|
||||||
|
|
||||||
To enable web search:
|
#### Search Provider Priority
|
||||||
|
|
||||||
1. **Option 1 (Recommended)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) for the best results.
|
PicoClaw automatically selects the best available search provider in this order:
|
||||||
2. **Option 2 (No Credit Card)**: If you don't have a key, we automatically fall back to **DuckDuckGo** (no key required).
|
1. **Perplexity** (if enabled and API key configured) - AI-powered search with citations
|
||||||
|
2. **Brave Search** (if enabled and API key configured) - Privacy-focused paid API ($5/1000 queries)
|
||||||
|
3. **SearXNG** (if enabled and base_url configured) - Self-hosted metasearch aggregating 70+ engines (free)
|
||||||
|
4. **DuckDuckGo** (if enabled, default fallback) - No API key required (free)
|
||||||
|
|
||||||
|
#### Web Search Configuration Options
|
||||||
|
|
||||||
|
**Option 1 (Best Results)**: Perplexity AI Search
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "YOUR_PERPLEXITY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2 (Paid API)**: Get an API key at [https://brave.com/search/api](https://brave.com/search/api) ($5/1000 queries, ~$5-6/month)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"brave": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 3 (Self-Hosted)**: Deploy your own [SearXNG](https://github.com/searxng/searxng) instance
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"searxng": {
|
||||||
|
"enabled": true,
|
||||||
|
"base_url": "http://your-server:8888",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Benefits of SearXNG:
|
||||||
|
- **Zero cost**: No API fees or rate limits
|
||||||
|
- **Privacy-focused**: Self-hosted, no tracking
|
||||||
|
- **Aggregate results**: Queries 70+ search engines simultaneously
|
||||||
|
- **Perfect for cloud VMs**: Solves datacenter IP blocking issues (Oracle Cloud, GCP, AWS, Azure)
|
||||||
|
- **No API key needed**: Just deploy and configure the base URL
|
||||||
|
|
||||||
|
**Option 4 (No Setup Required)**: DuckDuckGo is enabled by default as fallback (no API key needed)
|
||||||
|
|
||||||
Add the key to `~/.picoclaw/config.json` if using Brave:
|
Add the key to `~/.picoclaw/config.json` if using Brave:
|
||||||
|
|
||||||
|
|
@ -1166,6 +1509,16 @@ Add the key to `~/.picoclaw/config.json` if using Brave:
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_PERPLEXITY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://your-searxng-instance:8888",
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1184,10 +1537,20 @@ This happens when another instance of the bot is running. Make sure only one `pi
|
||||||
|
|
||||||
## 📝 API Key Comparison
|
## 📝 API Key Comparison
|
||||||
|
|
||||||
| Service | Free Tier | Use Case |
|
| Service | Free Tier | Use Case |
|
||||||
| ---------------- | ------------------- | ------------------------------------- |
|
| ---------------- | ------------------------ | ------------------------------------- |
|
||||||
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
||||||
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) |
|
||||||
| **Brave Search** | 2000 queries/month | Web search functionality |
|
| **Zhipu** | 200K tokens/month | Suitable for Chinese users |
|
||||||
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
| **Brave Search** | Paid ($5/1000 queries) | Web search functionality |
|
||||||
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
|
||||||
|
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
||||||
|
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
||||||
|
| **LongCat** | Up to 5M tokens/day | Fast inference (free tier) |
|
||||||
|
| **ModelScope** | 2000 requests/day | Free inference (Qwen, GLM, DeepSeek, etc.) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
208
README.pt-br.md
208
README.pt-br.md
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>
|
<h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>
|
||||||
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<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://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
|
|
||||||
⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz.
|
⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz.
|
||||||
|
|
||||||
🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM e x86. Um clique e já era!
|
🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM, MIPS e x86. Um clique e já era!
|
||||||
|
|
||||||
🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop.
|
🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop.
|
||||||
|
|
||||||
|
|
@ -165,47 +165,49 @@ Você tambêm pode rodar o PicoClaw usando Docker Compose sem instalar nada loca
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Configure suas API keys
|
# 2. Primeiro uso — gera docker/data/config.json automaticamente e para
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc.
|
# O contêiner exibe "First-run setup complete." e para.
|
||||||
|
|
||||||
# 3. Build & Iniciar
|
# 3. Configure suas API keys
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # Chaves de API do provedor, tokens de bot, etc.
|
||||||
|
|
||||||
|
# 4. Iniciar
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`.
|
> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Ver logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
# 4. Ver logs
|
# 6. Parar
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
|
|
||||||
# 5. Parar
|
|
||||||
docker compose --profile gateway down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Modo Agente (Execução única)
|
### Modo Agente (Execução única)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Fazer uma pergunta
|
# Fazer uma pergunta
|
||||||
docker compose run --rm picoclaw-agent -m "Quanto e 2+2?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Quanto e 2+2?"
|
||||||
|
|
||||||
# Modo interativo
|
# Modo interativo
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rebuild
|
### Atualizar
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Início Rápido
|
### 🚀 Início Rápido
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Configure sua API key em `~/.picoclaw/config.json`.
|
> Configure sua API key em `~/.picoclaw/config.json`. Obtenha API keys: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Busca web é **opcional** — obtenha a [API Tavily](https://tavily.com) gratuita (1000 consultas grátis/mês) ou a [Brave Search API](https://brave.com/search/api) (2000 consultas grátis/mês).
|
||||||
> Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas grátis/mês) ou use o fallback automático integrado.
|
|
||||||
|
|
||||||
**1. Inicializar**
|
**1. Inicializar**
|
||||||
|
|
||||||
|
|
@ -219,15 +221,21 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model_name": "gpt4"
|
"model_name": "gpt-5.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
@ -246,6 +254,9 @@ picoclaw onboard
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Novo**: O formato de configuração `model_list` permite adicionar provedores sem alterar código. Veja [Configuração de Modelo](#configuração-de-modelo-model_list) para detalhes.
|
||||||
|
> `request_timeout` é opcional e usa segundos. Se omitido ou definido como `<= 0`, o PicoClaw usa o timeout padrão (120s).
|
||||||
|
|
||||||
**3. Obter API Keys**
|
**3. Obter API Keys**
|
||||||
|
|
||||||
* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
|
|
@ -274,7 +285,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom.
|
||||||
| **QQ** | Fácil (AppID + AppSecret) |
|
| **QQ** | Fácil (AppID + AppSecret) |
|
||||||
| **DingTalk** | Médio (credenciais do app) |
|
| **DingTalk** | Médio (credenciais do app) |
|
||||||
| **LINE** | Médio (credenciais + webhook URL) |
|
| **LINE** | Médio (credenciais + webhook URL) |
|
||||||
| **WeCom** | Médio (CorpID + configuração webhook) |
|
| **WeCom AI Bot** | Médio (Token + chave AES) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recomendado)</summary>
|
<summary><b>Telegram</b> (Recomendado)</summary>
|
||||||
|
|
@ -442,8 +453,6 @@ picoclaw gateway
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18791,
|
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -457,11 +466,13 @@ O LINE requer HTTPS para webhooks. Use um reverse proxy ou tunnel:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Exemplo com ngrok
|
# Exemplo com ngrok
|
||||||
ngrok http 18791
|
ngrok http 18790
|
||||||
```
|
```
|
||||||
|
|
||||||
Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**.
|
Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**.
|
||||||
|
|
||||||
|
> **Nota**: O webhook do LINE é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Use um proxy reverso/HTTPS ou túnel (como ngrok) para expor o Gateway de forma segura quando necessário.
|
||||||
|
|
||||||
**4. Executar**
|
**4. Executar**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -470,19 +481,20 @@ picoclaw gateway
|
||||||
|
|
||||||
> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original.
|
> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original.
|
||||||
|
|
||||||
> **Docker Compose**: Adicione `ports: ["18791:18791"]` ao serviço `picoclaw-gateway` para expor a porta do webhook.
|
> **Docker Compose**: Se você usa Docker Compose, exponha o Gateway (padrão 127.0.0.1:18790) se precisar acessar o webhook LINE externamente, por exemplo `ports: ["18790:18790"]`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>WeCom (WeChat Work)</b></summary>
|
<summary><b>WeCom (WeChat Work)</b></summary>
|
||||||
|
|
||||||
O PicoClaw suporta dois tipos de integração WeCom:
|
O PicoClaw suporta três tipos de integração WeCom:
|
||||||
|
|
||||||
**Opção 1: WeCom Bot (Robô Inteligente)** - Configuração mais fácil, suporta chats em grupo
|
**Opção 1: WeCom Bot (Robô)** - Configuração mais fácil, suporta chats em grupo
|
||||||
**Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas
|
**Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas, somente chat privado
|
||||||
|
**Opção 3: WeCom AI Bot (Robô Inteligente)** - Bot IA oficial, respostas em streaming, suporta grupo e privado
|
||||||
|
|
||||||
Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para instruções detalhadas.
|
Veja o [Guia de Configuração WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) para instruções detalhadas.
|
||||||
|
|
||||||
**Configuração Rápida - WeCom Bot:**
|
**Configuração Rápida - WeCom Bot:**
|
||||||
|
|
||||||
|
|
@ -501,8 +513,6 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18793,
|
|
||||||
"webhook_path": "/webhook/wecom",
|
"webhook_path": "/webhook/wecom",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -510,6 +520,8 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Nota**: O webhook do WeCom Bot é atendido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Use um proxy reverso/HTTPS ou túnel para expor o Gateway em produção.
|
||||||
|
|
||||||
**Configuração Rápida - WeCom App:**
|
**Configuração Rápida - WeCom App:**
|
||||||
|
|
||||||
**1. Criar um aplicativo**
|
**1. Criar um aplicativo**
|
||||||
|
|
@ -521,7 +533,7 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
|
||||||
**2. Configurar recebimento de mensagens**
|
**2. Configurar recebimento de mensagens**
|
||||||
|
|
||||||
* Nos detalhes do aplicativo, clique em "Receber Mensagens" → "Configurar API"
|
* Nos detalhes do aplicativo, clique em "Receber Mensagens" → "Configurar API"
|
||||||
* Defina a URL como `http://your-server:18792/webhook/wecom-app`
|
* Defina a URL como `http://your-server:18790/webhook/wecom-app`
|
||||||
* Gere o **Token** e o **EncodingAESKey**
|
* Gere o **Token** e o **EncodingAESKey**
|
||||||
|
|
||||||
**3. Configurar**
|
**3. Configurar**
|
||||||
|
|
@ -536,8 +548,6 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
|
||||||
"agent_id": 1000002,
|
"agent_id": 1000002,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
"webhook_path": "/webhook/wecom-app",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -551,7 +561,40 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Nota**: O WeCom App requer a abertura da porta 18792 para callbacks de webhook. Use um proxy reverso para HTTPS em produção.
|
> **Nota**: O WeCom App (callbacks de webhook) é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Em produção use um proxy reverso HTTPS para expor a porta do Gateway, ou atualize `PICOCLAW_GATEWAY_HOST` para `0.0.0.0` se necessário.
|
||||||
|
|
||||||
|
**Configuração Rápida - WeCom AI Bot:**
|
||||||
|
|
||||||
|
**1. Criar um AI Bot**
|
||||||
|
|
||||||
|
* Acesse o Console de Administração WeCom → Gerenciamento de Aplicativos → AI Bot
|
||||||
|
* Configure a URL de callback: `http://your-server:18791/webhook/wecom-aibot`
|
||||||
|
* Copie o **Token** e gere o **EncodingAESKey**
|
||||||
|
|
||||||
|
**2. Configurar**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_aibot": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
"allow_from": [],
|
||||||
|
"welcome_message": "Olá! Como posso ajudá-lo?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Executar**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Nota**: O WeCom AI Bot usa protocolo de pull em streaming — sem preocupações com timeout de resposta. Tarefas longas (>5,5 min) alternam automaticamente para entrega via `response_url`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -565,6 +608,31 @@ Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma única men
|
||||||
|
|
||||||
Arquivo de configuração: `~/.picoclaw/config.json`
|
Arquivo de configuração: `~/.picoclaw/config.json`
|
||||||
|
|
||||||
|
### Variáveis de Ambiente
|
||||||
|
|
||||||
|
Você pode substituir os caminhos padrão usando variáveis de ambiente. Isso é útil para instalações portáteis, implantações em contêineres ou para executar o picoclaw como um serviço do sistema. Essas variáveis são independentes e controlam caminhos diferentes.
|
||||||
|
|
||||||
|
| Variável | Descrição | Caminho Padrão |
|
||||||
|
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||||
|
| `PICOCLAW_CONFIG` | Substitui o caminho para o arquivo de configuração. Isso informa diretamente ao picoclaw qual `config.json` carregar, ignorando todos os outros locais. | `~/.picoclaw/config.json` |
|
||||||
|
| `PICOCLAW_HOME` | Substitui o diretório raiz dos dados do picoclaw. Isso altera o local padrão do `workspace` e de outros diretórios de dados. | `~/.picoclaw` |
|
||||||
|
|
||||||
|
**Exemplos:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Executar o picoclaw usando um arquivo de configuração específico
|
||||||
|
# O caminho do workspace será lido de dentro desse arquivo de configuração
|
||||||
|
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||||
|
|
||||||
|
# Executar o picoclaw com todos os seus dados armazenados em /opt/picoclaw
|
||||||
|
# A configuração será carregada do ~/.picoclaw/config.json padrão
|
||||||
|
# O workspace será criado em /opt/picoclaw/workspace
|
||||||
|
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
|
|
||||||
|
# Use ambos para uma configuração totalmente personalizada
|
||||||
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
### Estrutura do Workspace
|
### Estrutura do Workspace
|
||||||
|
|
||||||
O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/workspace`):
|
O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/workspace`):
|
||||||
|
|
@ -580,7 +648,6 @@ O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/worksp
|
||||||
├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min)
|
├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min)
|
||||||
├── IDENTITY.md # Identidade do Agente
|
├── IDENTITY.md # Identidade do Agente
|
||||||
├── SOUL.md # Alma do Agente
|
├── SOUL.md # Alma do Agente
|
||||||
├── TOOLS.md # Descrição das ferramentas
|
|
||||||
└── USER.md # Preferencias do usuario
|
└── USER.md # Preferencias do usuario
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -758,12 +825,13 @@ O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se com
|
||||||
### Provedores
|
### Provedores
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serão automaticamente transcritas.
|
> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de áudio de qualquer canal serão automaticamente transcritas no nível do agente.
|
||||||
|
|
||||||
| Provedor | Finalidade | Obter API Key |
|
| Provedor | Finalidade | Obter API Key |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](bigmodel.cn) |
|
| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](bigmodel.cn) |
|
||||||
|
| `volcengine` | LLM(Volcengine direto) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||||
| `openrouter` (Em teste) | LLM (recomendado, acesso a todos os modelos) | [openrouter.ai](https://openrouter.ai) |
|
| `openrouter` (Em teste) | LLM (recomendado, acesso a todos os modelos) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) |
|
| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) |
|
||||||
|
|
@ -909,8 +977,11 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) |
|
||||||
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
|
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obter Chave](https://www.byteplus.com) |
|
||||||
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obter Chave](https://longcat.chat/platform) |
|
||||||
|
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Obter Token](https://modelscope.cn/my/tokens) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
|
@ -920,8 +991,13 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -937,7 +1013,7 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -948,8 +1024,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VolcEngine (Doubao)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -973,6 +1058,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
||||||
```
|
```
|
||||||
> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
|
> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
|
||||||
|
|
||||||
|
**Proxy/API personalizada**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Balanceamento de Carga
|
#### Balanceamento de Carga
|
||||||
|
|
||||||
Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
|
Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
|
||||||
|
|
@ -981,14 +1077,14 @@ Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-r
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -1120,7 +1216,15 @@ Isso acontece quando outra instância do bot está em execução. Certifique-se
|
||||||
| Serviço | Plano Gratuito | Caso de Uso |
|
| Serviço | Plano Gratuito | Caso de Uso |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) |
|
| **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) |
|
||||||
| **Zhipu** | 200K tokens/mês | Melhor para usuários chineses |
|
| **Volcengine CodingPlan** | ¥9,9/primeiro mês | Ideal para usuários chineses, múltiplos modelos SOTA (Doubao, DeepSeek, etc.) |
|
||||||
|
| **Zhipu** | 200K tokens/mês | Adequado para usuários chineses |
|
||||||
| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web |
|
| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web |
|
||||||
| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) |
|
| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) |
|
||||||
| **Cerebras** | Plano gratuito disponível | Inferência ultra-rápida (Llama 3.3 70B) |
|
| **Cerebras** | Plano gratuito disponível | Inferência ultra-rápida (Llama 3.3 70B) |
|
||||||
|
| **ModelScope** | 2000 requisições/dia | Inferência gratuita (Qwen, GLM, DeepSeek, etc.) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
208
README.vi.md
208
README.vi.md
|
|
@ -1,13 +1,13 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
|
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
|
||||||
|
|
||||||
<h3>Phần cứng $10 · RAM 10MB · Khởi động 1 giây · 皮皮虾,我们走!</h3>
|
<h3>Phần cứng $10 · RAM 10MB · Khởi động 1 giây · Nào, xuất phát!</h3>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<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://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/ROADMAP.md) — rất mong đón nhận sự tham gia của bạn!
|
2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/ROADMAP.md) — rất mong đón nhận sự tham gia của bạn!
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
|
2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
|
||||||
🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
|
🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
|
||||||
|
|
||||||
2026-02-09 🎉 PicoClaw chính thức ra mắt! Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường!
|
2026-02-09 🎉 PicoClaw chính thức ra mắt! Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường!
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
|
|
||||||
⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz.
|
⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz.
|
||||||
|
|
||||||
🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM và x86. Một click là chạy!
|
🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM, MIPS và x86. Một click là chạy!
|
||||||
|
|
||||||
🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.
|
🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.
|
||||||
|
|
||||||
|
|
@ -145,47 +145,49 @@ Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cà
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Thiết lập API Key
|
# 2. Lần chạy đầu tiên — tự tạo docker/data/config.json rồi dừng lại
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v.
|
# Container hiển thị "First-run setup complete." rồi tự dừng.
|
||||||
|
|
||||||
# 3. Build & Khởi động
|
# 3. Thiết lập API Key
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # API key của provider, bot token, v.v.
|
||||||
|
|
||||||
|
# 4. Khởi động
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`.
|
> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Xem logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
# 4. Xem logs
|
# 6. Dừng
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
|
|
||||||
# 5. Dừng
|
|
||||||
docker compose --profile gateway down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Chế độ Agent (chạy một lần)
|
### Chế độ Agent (chạy một lần)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Đặt câu hỏi
|
# Đặt câu hỏi
|
||||||
docker compose run --rm picoclaw-agent -m "2+2 bằng mấy?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 bằng mấy?"
|
||||||
|
|
||||||
# Chế độ tương tác
|
# Chế độ tương tác
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build lại
|
### Cập nhật
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Bắt đầu nhanh
|
### 🚀 Bắt đầu nhanh
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Thiết lập API key trong `~/.picoclaw/config.json`.
|
> Thiết lập API key trong `~/.picoclaw/config.json`. Lấy API key: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Tìm kiếm web là **tùy chọn** — lấy [Tavily API](https://tavily.com) miễn phí (1000 truy vấn/tháng) hoặc [Brave Search API](https://brave.com/search/api) (2000 truy vấn/tháng).
|
||||||
> Lấy API key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> Tìm kiếm web là **tùy chọn** — lấy [Brave Search API](https://brave.com/search/api) miễn phí (2000 truy vấn/tháng) hoặc dùng tính năng auto fallback tích hợp sẵn.
|
|
||||||
|
|
||||||
**1. Khởi tạo**
|
**1. Khởi tạo**
|
||||||
|
|
||||||
|
|
@ -199,9 +201,15 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -220,6 +228,9 @@ picoclaw onboard
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Mới**: Định dạng cấu hình `model_list` cho phép thêm nhà cung cấp mà không cần thay đổi mã nguồn. Xem [Cấu hình Mô hình](#cấu-hình-mô-hình-model_list) để biết chi tiết.
|
||||||
|
> `request_timeout` là tùy chọn và dùng đơn vị giây. Nếu bỏ qua hoặc đặt `<= 0`, PicoClaw sẽ dùng timeout mặc định (120s).
|
||||||
|
|
||||||
**3. Lấy API Key**
|
**3. Lấy API Key**
|
||||||
|
|
||||||
* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
|
|
@ -248,7 +259,7 @@ Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk, LINE hoặc WeCom.
|
||||||
| **QQ** | Dễ (AppID + AppSecret) |
|
| **QQ** | Dễ (AppID + AppSecret) |
|
||||||
| **DingTalk** | Trung bình (app credentials) |
|
| **DingTalk** | Trung bình (app credentials) |
|
||||||
| **LINE** | Trung bình (credentials + webhook URL) |
|
| **LINE** | Trung bình (credentials + webhook URL) |
|
||||||
| **WeCom** | Trung bình (CorpID + cấu hình webhook) |
|
| **WeCom AI Bot** | Trung bình (Token + khóa AES) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Khuyên dùng)</summary>
|
<summary><b>Telegram</b> (Khuyên dùng)</summary>
|
||||||
|
|
@ -416,8 +427,6 @@ picoclaw gateway
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18791,
|
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -431,7 +440,7 @@ LINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ví dụ với ngrok
|
# Ví dụ với ngrok
|
||||||
ngrok http 18791
|
ngrok http 18790
|
||||||
```
|
```
|
||||||
|
|
||||||
Sau đó cài đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**.
|
Sau đó cài đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**.
|
||||||
|
|
@ -444,19 +453,20 @@ picoclaw gateway
|
||||||
|
|
||||||
> Trong nhóm chat, bot chỉ phản hồi khi được @mention. Các câu trả lời sẽ trích dẫn tin nhắn gốc.
|
> Trong nhóm chat, bot chỉ phản hồi khi được @mention. Các câu trả lời sẽ trích dẫn tin nhắn gốc.
|
||||||
|
|
||||||
> **Docker Compose**: Thêm `ports: ["18791:18791"]` vào service `picoclaw-gateway` để mở port webhook.
|
> **Docker Compose**: Nếu bạn cần mở port webhook cục bộ, hãy thêm một rule chuyển tiếp từ port Gateway (mặc định 18790) tới host. Lưu ý: LINE webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790).
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>WeCom (WeChat Work)</b></summary>
|
<summary><b>WeCom (WeChat Work)</b></summary>
|
||||||
|
|
||||||
PicoClaw hỗ trợ hai loại tích hợp WeCom:
|
PicoClaw hỗ trợ ba loại tích hợp WeCom:
|
||||||
|
|
||||||
**Tùy chọn 1: WeCom Bot (Robot Thông minh)** - Thiết lập dễ dàng hơn, hỗ trợ chat nhóm
|
**Tùy chọn 1: WeCom Bot (Robot)** - Thiết lập dễ dàng hơn, hỗ trợ chat nhóm
|
||||||
**Tùy chọn 2: WeCom App (Ứng dụng Tự xây dựng)** - Nhiều tính năng hơn, nhắn tin chủ động
|
**Tùy chọn 2: WeCom App (Ứng dụng Tùy chỉnh)** - Nhiều tính năng hơn, nhắn tin chủ động, chỉ chat riêng tư
|
||||||
|
**Tùy chọn 3: WeCom AI Bot (Bot Thông Minh)** - Bot AI chính thức, phản hồi streaming, hỗ trợ nhóm và riêng tư
|
||||||
|
|
||||||
Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) để biết hướng dẫn chi tiết.
|
Xem [Hướng dẫn Cấu hình WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) để biết hướng dẫn chi tiết.
|
||||||
|
|
||||||
**Thiết lập Nhanh - WeCom Bot:**
|
**Thiết lập Nhanh - WeCom Bot:**
|
||||||
|
|
||||||
|
|
@ -475,8 +485,6 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18793,
|
|
||||||
"webhook_path": "/webhook/wecom",
|
"webhook_path": "/webhook/wecom",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -484,6 +492,8 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Lưu ý:** Các endpoint webhook của WeCom Bot được phục vụ bởi máy chủ Gateway HTTP dùng chung (mặc định 127.0.0.1:18790). Nếu bạn cần truy cập từ bên ngoài, hãy cấu hình reverse proxy hoặc mở cổng Gateway tương ứng.
|
||||||
|
|
||||||
**Thiết lập Nhanh - WeCom App:**
|
**Thiết lập Nhanh - WeCom App:**
|
||||||
|
|
||||||
**1. Tạo ứng dụng**
|
**1. Tạo ứng dụng**
|
||||||
|
|
@ -495,7 +505,7 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
|
||||||
**2. Cấu hình nhận tin nhắn**
|
**2. Cấu hình nhận tin nhắn**
|
||||||
|
|
||||||
* Trong chi tiết ứng dụng, nhấp vào "Nhận Tin nhắn" → "Thiết lập API"
|
* Trong chi tiết ứng dụng, nhấp vào "Nhận Tin nhắn" → "Thiết lập API"
|
||||||
* Đặt URL thành `http://your-server:18792/webhook/wecom-app`
|
* Đặt URL thành `http://your-server:18790/webhook/wecom-app`
|
||||||
* Tạo **Token** và **EncodingAESKey**
|
* Tạo **Token** và **EncodingAESKey**
|
||||||
|
|
||||||
**3. Cấu hình**
|
**3. Cấu hình**
|
||||||
|
|
@ -510,8 +520,6 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
|
||||||
"agent_id": 1000002,
|
"agent_id": 1000002,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
"webhook_path": "/webhook/wecom-app",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -525,7 +533,40 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Lưu ý**: WeCom App yêu cầu mở cổng 18792 cho callback webhook. Sử dụng proxy ngược cho HTTPS trong môi trường sản xuất.
|
> **Lưu ý**: WeCom App callback webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790). Sử dụng proxy ngược để cung cấp HTTPS trong môi trường production nếu cần.
|
||||||
|
|
||||||
|
**Thiết lập Nhanh - WeCom AI Bot:**
|
||||||
|
|
||||||
|
**1. Tạo AI Bot**
|
||||||
|
|
||||||
|
* Truy cập Bảng điều khiển Quản trị WeCom → Quản lý Ứng dụng → AI Bot
|
||||||
|
* Cấu hình URL callback: `http://your-server:18791/webhook/wecom-aibot`
|
||||||
|
* Sao chép **Token** và tạo **EncodingAESKey**
|
||||||
|
|
||||||
|
**2. Cấu hình**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_aibot": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
"allow_from": [],
|
||||||
|
"welcome_message": "Xin chào! Tôi có thể giúp gì cho bạn?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Chạy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Lưu ý**: WeCom AI Bot sử dụng giao thức pull streaming — không lo timeout phản hồi. Tác vụ dài (>5,5 phút) tự động chuyển sang gửi qua `response_url`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -539,6 +580,31 @@ Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một
|
||||||
|
|
||||||
File cấu hình: `~/.picoclaw/config.json`
|
File cấu hình: `~/.picoclaw/config.json`
|
||||||
|
|
||||||
|
### Biến môi trường
|
||||||
|
|
||||||
|
Bạn có thể ghi đè các đường dẫn mặc định bằng cách sử dụng các biến môi trường. Điều này hữu ích cho việc cài đặt di động, triển khai container hóa hoặc chạy picoclaw như một dịch vụ hệ thống. Các biến này độc lập và kiểm soát các đường dẫn khác nhau.
|
||||||
|
|
||||||
|
| Biến | Mô tả | Đường dẫn mặc định |
|
||||||
|
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||||
|
| `PICOCLAW_CONFIG` | Ghi đè đường dẫn đến file cấu hình. Điều này trực tiếp yêu cầu picoclaw tải file `config.json` nào, bỏ qua tất cả các vị trí khác. | `~/.picoclaw/config.json` |
|
||||||
|
| `PICOCLAW_HOME` | Ghi đè thư mục gốc cho dữ liệu picoclaw. Điều này thay đổi vị trí mặc định của `workspace` và các thư mục dữ liệu khác. | `~/.picoclaw` |
|
||||||
|
|
||||||
|
**Ví dụ:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Chạy picoclaw bằng một file cấu hình cụ thể
|
||||||
|
# Đường dẫn workspace sẽ được đọc từ trong file cấu hình đó
|
||||||
|
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||||
|
|
||||||
|
# Chạy picoclaw với tất cả dữ liệu được lưu trữ trong /opt/picoclaw
|
||||||
|
# Cấu hình sẽ được tải từ ~/.picoclaw/config.json mặc định
|
||||||
|
# Workspace sẽ được tạo tại /opt/picoclaw/workspace
|
||||||
|
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
|
|
||||||
|
# Sử dụng cả hai để có thiết lập tùy chỉnh hoàn toàn
|
||||||
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
### Cấu trúc Workspace
|
### Cấu trúc Workspace
|
||||||
|
|
||||||
PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`):
|
PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`):
|
||||||
|
|
@ -554,7 +620,6 @@ PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định:
|
||||||
├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút)
|
├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút)
|
||||||
├── IDENTITY.md # Danh tính Agent
|
├── IDENTITY.md # Danh tính Agent
|
||||||
├── SOUL.md # Tâm hồn/Tính cách Agent
|
├── SOUL.md # Tâm hồn/Tính cách Agent
|
||||||
├── TOOLS.md # Mô tả công cụ
|
|
||||||
└── USER.md # Tùy chọn người dùng
|
└── USER.md # Tùy chọn người dùng
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -732,12 +797,13 @@ Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và
|
||||||
### Nhà cung cấp (Providers)
|
### Nhà cung cấp (Providers)
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn thoại trên Telegram sẽ được tự động chuyển thành văn bản.
|
> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn âm thanh từ bất kỳ kênh nào sẽ được tự động chuyển thành văn bản ở cấp độ agent.
|
||||||
|
|
||||||
| Nhà cung cấp | Mục đích | Lấy API Key |
|
| Nhà cung cấp | Mục đích | Lấy API Key |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `gemini` | LLM (Gemini trực tiếp) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini trực tiếp) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM (Zhipu trực tiếp) | [bigmodel.cn](bigmodel.cn) |
|
| `zhipu` | LLM (Zhipu trực tiếp) | [bigmodel.cn](bigmodel.cn) |
|
||||||
|
| `volcengine` | LLM(Volcengine trực tiếp) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||||
| `openrouter` (Đang thử nghiệm) | LLM (khuyên dùng, truy cập mọi model) | [openrouter.ai](https://openrouter.ai) |
|
| `openrouter` (Đang thử nghiệm) | LLM (khuyên dùng, truy cập mọi model) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `anthropic` (Đang thử nghiệm) | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic` (Đang thử nghiệm) | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai` (Đang thử nghiệm) | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) |
|
| `openai` (Đang thử nghiệm) | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) |
|
||||||
|
|
@ -880,8 +946,11 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) |
|
||||||
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
|
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Lấy Khóa](https://www.byteplus.com) |
|
||||||
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Lấy Key](https://longcat.chat/platform) |
|
||||||
|
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Lấy Token](https://modelscope.cn/my/tokens) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
|
| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
|
@ -891,8 +960,13 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -908,7 +982,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -919,8 +993,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VolcEngine (Doubao)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -944,6 +1027,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
```
|
```
|
||||||
> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
|
> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
|
||||||
|
|
||||||
|
**Proxy/API tùy chỉnh**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Cân bằng Tải tải
|
#### Cân bằng Tải tải
|
||||||
|
|
||||||
Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
|
Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
|
||||||
|
|
@ -952,14 +1046,14 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -1091,6 +1185,14 @@ Một số nhà cung cấp (như Zhipu) có bộ lọc nội dung nghiêm ngặt
|
||||||
| Dịch vụ | Gói miễn phí | Trường hợp sử dụng |
|
| Dịch vụ | Gói miễn phí | Trường hợp sử dụng |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) |
|
| **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) |
|
||||||
| **Zhipu** | 200K tokens/tháng | Tốt nhất cho người dùng Trung Quốc |
|
| **Volcengine CodingPlan** | ¥9.9/tháng đầu | Tốt nhất cho người dùng Trung Quốc, nhiều mô hình SOTA (Doubao, DeepSeek, v.v.) |
|
||||||
|
| **Zhipu** | 200K tokens/tháng | Phù hợp cho người dùng Trung Quốc |
|
||||||
| **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web |
|
| **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web |
|
||||||
| **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) |
|
| **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) |
|
||||||
|
| **ModelScope** | 2000 yêu cầu/ngày | Suy luận miễn phí (Qwen, GLM, DeepSeek, v.v.) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
210
README.zh.md
210
README.zh.md
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
||||||
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<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://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
|
|
||||||
⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。
|
⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。
|
||||||
|
|
||||||
🌍 **真正可移植**: 跨 RISC-V、ARM 和 x86 架构的单二进制文件,一键运行!
|
🌍 **真正可移植**: 跨 RISC-V、ARM、MIPS 和 x86 架构的单二进制文件,一键运行!
|
||||||
|
|
||||||
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
||||||
|
|
||||||
|
|
@ -117,7 +117,7 @@ pkg install proot
|
||||||
termux-chroot ./picoclaw-linux-arm64 onboard
|
termux-chroot ./picoclaw-linux-arm64 onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
|
然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
|
||||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
### 🐜 创新的低占用部署
|
### 🐜 创新的低占用部署
|
||||||
|
|
@ -166,49 +166,49 @@ make install
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. 设置 API Key
|
# 2. 首次运行 — 自动生成 docker/data/config.json 后退出
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等
|
# 容器打印 "First-run setup complete." 后自动停止
|
||||||
|
|
||||||
# 3. 构建并启动
|
# 3. 填写 API Key 等配置
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # 设置 provider API key、Bot Token 等
|
||||||
|
|
||||||
|
# 4. 正式启动
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
**Docker 用户**: 默认情况下, Gateway监听 `127.0.0.1`,这使得这个端口未暴露到容器外。如果你需要通过端口映射访问健康检查接口, 请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。
|
> **Docker 用户**: 默认情况下, Gateway 监听 `127.0.0.1`,该端口不会暴露到容器外。如果需要通过端口映射访问健康检查接口,请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。
|
||||||
|
|
||||||
# 4. 查看日志
|
```bash
|
||||||
docker compose logs -f picoclaw-gateway
|
# 5. 查看日志
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
# 5. 停止
|
|
||||||
docker compose --profile gateway down
|
|
||||||
|
|
||||||
|
# 6. 停止
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent 模式 (一次性运行)
|
### Agent 模式 (一次性运行)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 提问
|
# 提问
|
||||||
docker compose run --rm picoclaw-agent -m "2+2 等于几?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 等于几?"
|
||||||
|
|
||||||
# 交互模式
|
# 交互模式
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 重新构建
|
### 更新镜像
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 快速开始
|
### 🚀 快速开始
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
|
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。获取 API Key: [火山引擎 (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。网络搜索是 **可选的** — 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)。
|
||||||
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
|
|
||||||
|
|
||||||
**1. 初始化 (Initialize)**
|
**1. 初始化 (Initialize)**
|
||||||
|
|
||||||
|
|
@ -224,7 +224,7 @@ picoclaw onboard
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt-5.4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20
|
||||||
|
|
@ -232,9 +232,15 @@ picoclaw onboard
|
||||||
},
|
},
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "your-api-key"
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "your-api-key",
|
||||||
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
|
@ -263,6 +269,7 @@ picoclaw onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
|
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
|
||||||
|
> `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。
|
||||||
|
|
||||||
**3. 获取 API Key**
|
**3. 获取 API Key**
|
||||||
|
|
||||||
|
|
@ -286,6 +293,8 @@ picoclaw agent -m "2+2 等于几?"
|
||||||
|
|
||||||
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
|
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
|
||||||
|
|
||||||
|
> **注意**: 所有 Webhook 类渠道(LINE、WeCom 等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。注意:飞书(Feishu)使用 WebSocket/SDK 模式,不通过该共享 HTTP webhook 服务器接收消息。
|
||||||
|
|
||||||
### 核心渠道
|
### 核心渠道
|
||||||
|
|
||||||
| 渠道 | 设置难度 | 特性说明 | 文档链接 |
|
| 渠道 | 设置难度 | 特性说明 | 文档链接 |
|
||||||
|
|
@ -293,14 +302,22 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
||||||
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
|
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
|
||||||
| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
|
| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
|
||||||
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
|
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
|
||||||
|
| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](docs/channels/matrix/README.zh.md) |
|
||||||
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
|
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
|
||||||
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
|
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
|
||||||
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)和自建应用(API) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) |
|
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](docs/channels/wecom/wecom_aibot/README.zh.md) |
|
||||||
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) |
|
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) |
|
||||||
| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
|
| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
|
||||||
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
||||||
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
||||||
|
|
||||||
|
### Telegram 命令注册(启动时自动同步)
|
||||||
|
|
||||||
|
PicoClaw 现在使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
|
||||||
|
Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。
|
||||||
|
|
||||||
|
如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
|
||||||
|
|
||||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||||
|
|
||||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||||
|
|
@ -311,6 +328,31 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
||||||
|
|
||||||
配置文件路径: `~/.picoclaw/config.json`
|
配置文件路径: `~/.picoclaw/config.json`
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
你可以使用环境变量覆盖默认路径。这对于便携安装、容器化部署或将 picoclaw 作为系统服务运行非常有用。这些变量是独立的,控制不同的路径。
|
||||||
|
|
||||||
|
| 变量 | 描述 | 默认路径 |
|
||||||
|
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||||
|
| `PICOCLAW_CONFIG` | 覆盖配置文件的路径。这直接告诉 picoclaw 加载哪个 `config.json`,忽略所有其他位置。 | `~/.picoclaw/config.json` |
|
||||||
|
| `PICOCLAW_HOME` | 覆盖 picoclaw 数据根目录。这会更改 `workspace` 和其他数据目录的默认位置。 | `~/.picoclaw` |
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用特定的配置文件运行 picoclaw
|
||||||
|
# 工作区路径将从该配置文件中读取
|
||||||
|
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||||
|
|
||||||
|
# 在 /opt/picoclaw 中存储所有数据运行 picoclaw
|
||||||
|
# 配置将从默认的 ~/.picoclaw/config.json 加载
|
||||||
|
# 工作区将在 /opt/picoclaw/workspace 创建
|
||||||
|
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
|
|
||||||
|
# 同时使用两者进行完全自定义设置
|
||||||
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
### 工作区布局 (Workspace Layout)
|
### 工作区布局 (Workspace Layout)
|
||||||
|
|
||||||
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`):
|
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`):
|
||||||
|
|
@ -326,11 +368,30 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
||||||
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
|
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
|
||||||
├── IDENTITY.md # Agent 身份设定
|
├── IDENTITY.md # Agent 身份设定
|
||||||
├── SOUL.md # Agent 灵魂/性格
|
├── SOUL.md # Agent 灵魂/性格
|
||||||
├── TOOLS.md # 工具描述
|
|
||||||
└── USER.md # 用户偏好
|
└── USER.md # 用户偏好
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 技能来源 (Skill Sources)
|
||||||
|
|
||||||
|
默认情况下,技能会按以下顺序加载:
|
||||||
|
|
||||||
|
1. `~/.picoclaw/workspace/skills`(工作区)
|
||||||
|
2. `~/.picoclaw/skills`(全局)
|
||||||
|
3. `<current-working-directory>/skills`(内置)
|
||||||
|
|
||||||
|
在高级/测试场景下,可通过以下环境变量覆盖内置技能目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
|
```
|
||||||
|
|
||||||
|
### 统一命令执行策略
|
||||||
|
|
||||||
|
- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。
|
||||||
|
- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。
|
||||||
|
- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。
|
||||||
|
- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。
|
||||||
### 心跳 / 周期性任务 (Heartbeat)
|
### 心跳 / 周期性任务 (Heartbeat)
|
||||||
|
|
||||||
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
|
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
|
||||||
|
|
@ -414,16 +475,17 @@ Agent 读取 HEARTBEAT.md
|
||||||
### 提供商 (Providers)
|
### 提供商 (Providers)
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
|
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,任意渠道的音频消息都将在 Agent 层面自动转录为文字。
|
||||||
|
|
||||||
| 提供商 | 用途 | 获取 API Key |
|
| 提供商 | 用途 | 获取 API Key |
|
||||||
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
||||||
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
|
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
|
||||||
| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
| `volcengine` | LLM (火山引擎直连) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||||
| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
|
| `openrouter` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
|
| `anthropic` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
|
| `openai` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
|
||||||
|
| `deepseek` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||||
| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||||
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
|
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||||
| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
|
| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
|
||||||
|
|
@ -456,8 +518,11 @@ Agent 读取 HEARTBEAT.md
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
|
||||||
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
|
| **火山引擎(Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://www.byteplus.com) |
|
||||||
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
|
||||||
|
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取 Token](https://modelscope.cn/my/tokens) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
|
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
|
@ -467,8 +532,13 @@ Agent 读取 HEARTBEAT.md
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -484,7 +554,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -496,8 +566,18 @@ Agent 读取 HEARTBEAT.md
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**火山引擎(Doubao)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -534,6 +614,26 @@ Agent 读取 HEARTBEAT.md
|
||||||
|
|
||||||
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
|
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
|
||||||
|
|
||||||
|
**Anthropic Messages API(原生格式)**
|
||||||
|
|
||||||
|
用于直接访问 Anthropic API 或仅支持 Anthropic 原生消息格式的自定义端点:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 使用 `anthropic-messages` 协议的场景:
|
||||||
|
> - 使用仅支持 Anthropic 原生 `/v1/messages` 端点的第三方代理(不支持 OpenAI 兼容的 `/v1/chat/completions`)
|
||||||
|
> - 连接到 MiniMax、Synthetic 等需要 Anthropic 原生消息格式的服务
|
||||||
|
> - 现有的 `anthropic` 协议返回 404 错误(说明端点不支持 OpenAI 兼容格式)
|
||||||
|
>
|
||||||
|
> **注意:** `anthropic` 协议使用 OpenAI 兼容格式(`/v1/chat/completions`),而 `anthropic-messages` 使用 Anthropic 原生格式(`/v1/messages`)。请根据端点支持的格式选择。
|
||||||
|
|
||||||
**Ollama (本地)**
|
**Ollama (本地)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -550,7 +650,8 @@ Agent 读取 HEARTBEAT.md
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -562,14 +663,14 @@ Agent 读取 HEARTBEAT.md
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -669,6 +770,10 @@ picoclaw agent -m "你好"
|
||||||
"model": "anthropic/claude-opus-4-5"
|
"model": "anthropic/claude-opus-4-5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"session": {
|
||||||
|
"dm_scope": "per-channel-peer",
|
||||||
|
"backlog_limit": 20
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"openrouter": {
|
"openrouter": {
|
||||||
"api_key": "sk-or-v1-xxx"
|
"api_key": "sk-or-v1-xxx"
|
||||||
|
|
@ -811,7 +916,16 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
||||||
| 服务 | 免费层级 | 适用场景 |
|
| 服务 | 免费层级 | 适用场景 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
||||||
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
| **火山引擎 CodingPlan** | 9.9 元/首月 | 最适合国内用户,多种 SOTA 模型(豆包、DeepSeek 等) |
|
||||||
|
| **智谱 (Zhipu)** | 200K tokens/月 | 适合中国用户 |
|
||||||
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
||||||
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
|
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
|
||||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
||||||
|
| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) |
|
||||||
|
| **ModelScope (魔搭)** | 2000 次请求/天 | 免费推理 (Qwen, GLM, DeepSeek 等) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
BIN
assets/logo.webp
Normal file
BIN
assets/logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 345 KiB |
49
cmd/picoclaw-launcher-tui/internal/config/store.go
Normal file
49
cmd/picoclaw-launcher-tui/internal/config/store.go
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
522
cmd/picoclaw-launcher-tui/internal/ui/app.go
Normal file
522
cmd/picoclaw-launcher-tui/internal/ui/app.go
Normal file
|
|
@ -0,0 +1,522 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"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)
|
||||||
|
root.AddItem(footerView(), 1, 0, false)
|
||||||
|
|
||||||
|
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("Menu", nil)
|
||||||
|
refreshMainMenu(menu, s)
|
||||||
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
switch event.Key() {
|
||||||
|
case tcell.KeyEsc:
|
||||||
|
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 (s *appState) countChannels() (enabled int, total int) {
|
||||||
|
c := s.config.Channels
|
||||||
|
entries := []bool{
|
||||||
|
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.Matrix.Enabled,
|
||||||
|
c.LINE.Enabled,
|
||||||
|
c.OneBot.Enabled,
|
||||||
|
c.WeCom.Enabled,
|
||||||
|
c.WeComApp.Enabled,
|
||||||
|
}
|
||||||
|
total = len(entries)
|
||||||
|
for _, v := range entries {
|
||||||
|
if v {
|
||||||
|
enabled++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return enabled, total
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
enabledCount, totalChannels := s.countChannels()
|
||||||
|
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(),
|
||||||
|
Action: func() {
|
||||||
|
s.push("model", s.modelMenu())
|
||||||
|
},
|
||||||
|
MainColor: func() *tcell.Color {
|
||||||
|
if modelReady {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
color := tcell.ColorGray
|
||||||
|
return &color
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: rootChannelLabel(channelReady),
|
||||||
|
Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
|
||||||
|
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 (None)"
|
||||||
|
}
|
||||||
|
return "Model (" + selected + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootModelDescription() string {
|
||||||
|
return "Using SPACE to choose your model"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootChannelLabel(valid bool) string {
|
||||||
|
if !valid {
|
||||||
|
return "Channel (no channel enabled)"
|
||||||
|
}
|
||||||
|
return "Channel"
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Matrix.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)
|
||||||
|
}
|
||||||
433
cmd/picoclaw-launcher-tui/internal/ui/channel.go
Normal file
433
cmd/picoclaw-launcher-tui/internal/ui/channel.go
Normal file
|
|
@ -0,0 +1,433 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *appState) buildChannelMenuItems() []MenuItem {
|
||||||
|
return []MenuItem{
|
||||||
|
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(
|
||||||
|
"Matrix",
|
||||||
|
"Matrix bot settings",
|
||||||
|
s.config.Channels.Matrix.Enabled,
|
||||||
|
func() { s.push("channel-matrix", s.matrixForm()) },
|
||||||
|
),
|
||||||
|
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()) },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) channelMenu() tview.Primitive {
|
||||||
|
menu := NewMenu("Channels", s.buildChannelMenuItems())
|
||||||
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshChannelMenuFromState(menu *Menu, s *appState) {
|
||||||
|
menu.applyItems(s.buildChannelMenuItems())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) telegramForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Telegram
|
||||||
|
form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) discordForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Discord
|
||||||
|
form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
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
|
||||||
|
})
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) qqForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.QQ
|
||||||
|
form := baseChannelForm("QQ", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) maixcamForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.MaixCam
|
||||||
|
form := baseChannelForm("MaixCam", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
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 })
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) whatsappForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.WhatsApp
|
||||||
|
form := baseChannelForm("WhatsApp", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) {
|
||||||
|
cfg.BridgeURL = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) feishuForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Feishu
|
||||||
|
form := baseChannelForm("Feishu", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) dingtalkForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.DingTalk
|
||||||
|
form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) slackForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Slack
|
||||||
|
form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) lineForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.LINE
|
||||||
|
form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) matrixForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Matrix
|
||||||
|
form := baseChannelForm("Matrix", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
form.AddInputField("Homeserver", cfg.Homeserver, 128, nil, func(text string) {
|
||||||
|
cfg.Homeserver = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) {
|
||||||
|
cfg.UserID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
|
||||||
|
cfg.AccessToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) {
|
||||||
|
cfg.DeviceID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddCheckbox("Join On Invite", cfg.JoinOnInvite, func(checked bool) {
|
||||||
|
cfg.JoinOnInvite = checked
|
||||||
|
})
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) onebotForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.OneBot
|
||||||
|
form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) wecomForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.WeCom
|
||||||
|
form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
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, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
addIntField(
|
||||||
|
form,
|
||||||
|
"Reply Timeout",
|
||||||
|
cfg.ReplyTimeout,
|
||||||
|
func(value int) { cfg.ReplyTimeout = value },
|
||||||
|
)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) {
|
||||||
|
return func(v bool) {
|
||||||
|
*enabledPtr = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) {
|
||||||
|
form.AddInputField("Allow From", strings.Join(*allowFrom, ","), 128, nil, func(text string) {
|
||||||
|
*allowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
16
cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go
Normal file
16
cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//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()
|
||||||
|
}
|
||||||
16
cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go
Normal file
16
cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//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()
|
||||||
|
}
|
||||||
72
cmd/picoclaw-launcher-tui/internal/ui/menu.go
Normal file
72
cmd/picoclaw-launcher-tui/internal/ui/menu.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
399
cmd/picoclaw-launcher-tui/internal/ui/model.go
Normal file
399
cmd/picoclaw-launcher-tui/internal/ui/model.go
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
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, 1+len(s.config.ModelList))
|
||||||
|
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))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Add model entry appended at the end so the models map to rows 1..N
|
||||||
|
items = append(items,
|
||||||
|
MenuItem{
|
||||||
|
Label: "**Add model**",
|
||||||
|
Description: "Append a new model entry",
|
||||||
|
Action: func() {
|
||||||
|
newName := s.nextAvailableModelName("new-model")
|
||||||
|
s.addModel(
|
||||||
|
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
||||||
|
)
|
||||||
|
s.push(
|
||||||
|
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||||
|
s.modelForm(len(s.config.ModelList)-1),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
menu := NewMenu("Models", items)
|
||||||
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.Rune() == ' ' {
|
||||||
|
row, _ := menu.GetSelection()
|
||||||
|
if row >= 0 && row < len(s.config.ModelList) {
|
||||||
|
model := s.config.ModelList[row]
|
||||||
|
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))
|
||||||
|
|
||||||
|
addInput(form, "Model Name", model.ModelName, func(value string) {
|
||||||
|
if value == "" {
|
||||||
|
s.showMessage("Invalid model name", "Model Name cannot be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.modelNameExists(value, index) {
|
||||||
|
s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldName := model.ModelName
|
||||||
|
model.ModelName = value
|
||||||
|
if s.config.Agents.Defaults.Model == oldName {
|
||||||
|
s.config.Agents.Defaults.Model = value
|
||||||
|
}
|
||||||
|
s.dirty = true
|
||||||
|
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||||
|
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() {
|
||||||
|
pageName := "confirm-delete-model"
|
||||||
|
if s.pages.HasPage(pageName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText("Are you sure you want to delete this model?").
|
||||||
|
AddButtons([]string{"Cancel", "Delete"}).
|
||||||
|
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||||
|
s.pages.RemovePage(pageName)
|
||||||
|
if buttonLabel == "Delete" {
|
||||||
|
s.deleteModel(index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
modal.SetTitle("Confirm Delete").SetBorder(true)
|
||||||
|
s.pages.AddPage(pageName, modal, true, true)
|
||||||
|
})
|
||||||
|
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
|
||||||
|
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, 1+len(s.config.ModelList))
|
||||||
|
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))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
items = append(items,
|
||||||
|
MenuItem{
|
||||||
|
Label: "**Add Model**",
|
||||||
|
Description: "Append a new model entry",
|
||||||
|
Action: func() {
|
||||||
|
newName := s.nextAvailableModelName("new-model")
|
||||||
|
s.addModel(
|
||||||
|
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
||||||
|
)
|
||||||
|
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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) modelNameExists(name string, excludeIndex int) bool {
|
||||||
|
target := strings.TrimSpace(name)
|
||||||
|
if target == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range s.config.ModelList {
|
||||||
|
if i == excludeIndex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.config.ModelList[i].ModelName) == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) nextAvailableModelName(base string) string {
|
||||||
|
name := strings.TrimSpace(base)
|
||||||
|
if name == "" {
|
||||||
|
name = "new-model"
|
||||||
|
}
|
||||||
|
if !s.modelNameExists(name, -1) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
for i := 2; ; i++ {
|
||||||
|
candidate := fmt.Sprintf("%s-%d", name, i)
|
||||||
|
if !s.modelNameExists(candidate, -1) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.showMessage(
|
||||||
|
"Test failed",
|
||||||
|
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
|
||||||
|
)
|
||||||
|
}
|
||||||
55
cmd/picoclaw-launcher-tui/internal/ui/style.go
Normal file
55
cmd/picoclaw-launcher-tui/internal/ui/style.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
colorBlue = "[#3e5db9]"
|
||||||
|
colorRed = "[#d54646]"
|
||||||
|
banner = "\r\n[::b]" +
|
||||||
|
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
||||||
|
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
||||||
|
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
|
||||||
|
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
|
||||||
|
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
||||||
|
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
|
||||||
|
"[:]"
|
||||||
|
)
|
||||||
|
|
||||||
|
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(banner)
|
||||||
|
text.SetBorder(false)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch"
|
||||||
|
|
||||||
|
func footerView() *tview.TextView {
|
||||||
|
text := tview.NewTextView()
|
||||||
|
text.SetTextAlign(tview.AlignCenter)
|
||||||
|
text.SetText(footerText)
|
||||||
|
text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor)
|
||||||
|
text.SetTextColor(tview.Styles.PrimaryTextColor)
|
||||||
|
text.SetBorder(false)
|
||||||
|
return text
|
||||||
|
}
|
||||||
15
cmd/picoclaw-launcher-tui/main.go
Normal file
15
cmd/picoclaw-launcher-tui/main.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := ui.Run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,7 +48,9 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
|
defer msgBus.Close()
|
||||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
defer agentLoop.Close()
|
||||||
|
|
||||||
// Print agent startup info (only for interactive mode)
|
// Print agent startup info (only for interactive mode)
|
||||||
startupInfo := agentLoop.GetStartupInfo()
|
startupInfo := agentLoop.GetStartupInfo()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -15,14 +16,17 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"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 {
|
switch provider {
|
||||||
case "openai":
|
case "openai":
|
||||||
return authLoginOpenAI(useDeviceCode)
|
return authLoginOpenAI(useDeviceCode)
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
return authLoginPasteToken(provider)
|
return authLoginAnthropic(useOauth)
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
return authLoginGoogleAntigravity()
|
return authLoginGoogleAntigravity()
|
||||||
default:
|
default:
|
||||||
|
|
@ -68,14 +72,14 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
||||||
// If no openai in ModelList, add it
|
// If no openai in ModelList, add it
|
||||||
if !foundOpenAI {
|
if !foundOpenAI {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
ModelName: "gpt-5.2",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.2",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update default model to use OpenAI
|
// Update default model to use OpenAI
|
||||||
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
|
appCfg.Agents.Defaults.ModelName = "gpt-5.4"
|
||||||
|
|
||||||
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||||
return fmt.Errorf("could not update config: %w", err)
|
return fmt.Errorf("could not update config: %w", err)
|
||||||
|
|
@ -86,7 +90,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
||||||
if cred.AccountID != "" {
|
if cred.AccountID != "" {
|
||||||
fmt.Printf("Account: %s\n", cred.AccountID)
|
fmt.Printf("Account: %s\n", cred.AccountID)
|
||||||
}
|
}
|
||||||
fmt.Println("Default model set to: gpt-5.2")
|
fmt.Println("Default model set to: gpt-5.4")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +167,81 @@ func authLoginGoogleAntigravity() error {
|
||||||
return nil
|
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 {
|
||||||
|
appCfg.Providers.Anthropic.AuthMethod = "oauth"
|
||||||
|
|
||||||
|
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) {
|
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -177,7 +256,10 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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 {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -217,13 +299,12 @@ func authLoginPasteToken(provider string) error {
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
ModelName: "claude-sonnet-4.6",
|
ModelName: defaultAnthropicModel,
|
||||||
Model: "anthropic/claude-sonnet-4.6",
|
Model: "anthropic/" + defaultAnthropicModel,
|
||||||
AuthMethod: "token",
|
AuthMethod: "token",
|
||||||
})
|
})
|
||||||
|
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
|
||||||
}
|
}
|
||||||
// Update default model
|
|
||||||
appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
|
||||||
case "openai":
|
case "openai":
|
||||||
appCfg.Providers.OpenAI.AuthMethod = "token"
|
appCfg.Providers.OpenAI.AuthMethod = "token"
|
||||||
// Update ModelList
|
// Update ModelList
|
||||||
|
|
@ -237,13 +318,13 @@ func authLoginPasteToken(provider string) error {
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
ModelName: "gpt-5.2",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.2",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "token",
|
AuthMethod: "token",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Update default model
|
// Update default model
|
||||||
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
|
appCfg.Agents.Defaults.ModelName = "gpt-5.4"
|
||||||
}
|
}
|
||||||
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||||
return fmt.Errorf("could not update config: %w", err)
|
return fmt.Errorf("could not update config: %w", err)
|
||||||
|
|
@ -360,6 +441,16 @@ func authStatusCmd() error {
|
||||||
if !cred.ExpiresAt.IsZero() {
|
if !cred.ExpiresAt.IsZero() {
|
||||||
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
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
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ func newLoginCommand() *cobra.Command {
|
||||||
var (
|
var (
|
||||||
provider string
|
provider string
|
||||||
useDeviceCode bool
|
useDeviceCode bool
|
||||||
|
useOauth bool
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|
@ -13,12 +14,16 @@ func newLoginCommand() *cobra.Command {
|
||||||
Short: "Login via OAuth or paste token",
|
Short: "Login via OAuth or paste token",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
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().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(&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")
|
_ = cmd.MarkFlagRequired("provider")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,42 @@
|
||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewGatewayCommand() *cobra.Command {
|
func NewGatewayCommand() *cobra.Command {
|
||||||
var debug bool
|
var debug bool
|
||||||
|
var noTruncate bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "gateway",
|
Use: "gateway",
|
||||||
Aliases: []string{"g"},
|
Aliases: []string{"g"},
|
||||||
Short: "Start picoclaw gateway",
|
Short: "Start picoclaw gateway",
|
||||||
Args: cobra.NoArgs,
|
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 {
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
return gatewayCmd(debug)
|
return gatewayCmd(debug)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||||
|
cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,70 @@ package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/agent"
|
"github.com/sipeed/picoclaw/pkg/agent"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"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/irc"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
|
||||||
|
_ "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/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/cron"
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
"github.com/sipeed/picoclaw/pkg/devices"
|
"github.com/sipeed/picoclaw/pkg/devices"
|
||||||
"github.com/sipeed/picoclaw/pkg/health"
|
"github.com/sipeed/picoclaw/pkg/health"
|
||||||
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
"github.com/sipeed/picoclaw/pkg/voice"
|
"github.com/sipeed/picoclaw/pkg/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Timeout constants for service operations
|
||||||
|
const (
|
||||||
|
serviceRestartTimeout = 30 * time.Second
|
||||||
|
serviceShutdownTimeout = 30 * time.Second
|
||||||
|
providerReloadTimeout = 30 * time.Second
|
||||||
|
gracefulShutdownTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// gatewayServices holds references to all running services
|
||||||
|
type gatewayServices struct {
|
||||||
|
CronService *cron.CronService
|
||||||
|
HeartbeatService *heartbeat.HeartbeatService
|
||||||
|
MediaStore media.MediaStore
|
||||||
|
ChannelManager *channels.Manager
|
||||||
|
DeviceService *devices.Service
|
||||||
|
HealthServer *health.Server
|
||||||
|
}
|
||||||
|
|
||||||
func gatewayCmd(debug bool) error {
|
func gatewayCmd(debug bool) error {
|
||||||
if debug {
|
if debug {
|
||||||
logger.SetLevel(logger.DEBUG)
|
logger.SetLevel(logger.DEBUG)
|
||||||
fmt.Println("🔍 Debug mode enabled")
|
fmt.Println("🔍 Debug mode enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configPath := internal.GetConfigPath()
|
||||||
cfg, err := internal.LoadConfig()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error loading config: %w", err)
|
return fmt.Errorf("error loading config: %w", err)
|
||||||
|
|
@ -69,9 +102,55 @@ func gatewayCmd(debug bool) error {
|
||||||
"skills_available": skillsInfo["available"],
|
"skills_available": skillsInfo["available"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Setup and start all services
|
||||||
|
services, err := setupAndStartServices(cfg, agentLoop, msgBus)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
go agentLoop.Run(ctx)
|
||||||
|
|
||||||
|
// Setup config file watcher for hot reload
|
||||||
|
configReloadChan, stopWatch := setupConfigWatcherPolling(configPath, debug)
|
||||||
|
defer stopWatch()
|
||||||
|
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, os.Interrupt)
|
||||||
|
|
||||||
|
// Main event loop - wait for signals or config changes
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-sigChan:
|
||||||
|
logger.Info("Shutting down...")
|
||||||
|
shutdownGateway(services, agentLoop, provider, true)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case newCfg := <-configReloadChan:
|
||||||
|
err := handleConfigReload(ctx, agentLoop, newCfg, &provider, services, msgBus)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Config reload failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupAndStartServices initializes and starts all services
|
||||||
|
func setupAndStartServices(
|
||||||
|
cfg *config.Config,
|
||||||
|
agentLoop *agent.AgentLoop,
|
||||||
|
msgBus *bus.MessageBus,
|
||||||
|
) (*gatewayServices, error) {
|
||||||
|
services := &gatewayServices{}
|
||||||
|
|
||||||
// Setup cron tool and service
|
// Setup cron tool and service
|
||||||
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
||||||
cronService := setupCronTool(
|
services.CronService = setupCronTool(
|
||||||
agentLoop,
|
agentLoop,
|
||||||
msgBus,
|
msgBus,
|
||||||
cfg.WorkspacePath(),
|
cfg.WorkspacePath(),
|
||||||
|
|
@ -79,20 +158,26 @@ func gatewayCmd(debug bool) error {
|
||||||
execTimeout,
|
execTimeout,
|
||||||
cfg,
|
cfg,
|
||||||
)
|
)
|
||||||
|
if err := services.CronService.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("error starting cron service: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println("✓ Cron service started")
|
||||||
|
|
||||||
heartbeatService := heartbeat.NewHeartbeatService(
|
// Setup heartbeat service
|
||||||
|
services.HeartbeatService = heartbeat.NewHeartbeatService(
|
||||||
cfg.WorkspacePath(),
|
cfg.WorkspacePath(),
|
||||||
cfg.Heartbeat.Interval,
|
cfg.Heartbeat.Interval,
|
||||||
cfg.Heartbeat.Enabled,
|
cfg.Heartbeat.Enabled,
|
||||||
)
|
)
|
||||||
heartbeatService.SetBus(msgBus)
|
services.HeartbeatService.SetBus(msgBus)
|
||||||
heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
services.HeartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||||
// Use cli:direct as fallback if no valid channel
|
// Use cli:direct as fallback if no valid channel
|
||||||
if channel == "" || chatID == "" {
|
if channel == "" || chatID == "" {
|
||||||
channel, chatID = "cli", "direct"
|
channel, chatID = "cli", "direct"
|
||||||
}
|
}
|
||||||
// Use ProcessHeartbeat - no session history, each heartbeat is independent
|
// Use ProcessHeartbeat - no session history, each heartbeat is independent
|
||||||
var response string
|
var response string
|
||||||
|
var err error
|
||||||
response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
||||||
|
|
@ -104,120 +189,423 @@ func gatewayCmd(debug bool) error {
|
||||||
// sent to user via processSystemMessage when the async task completes
|
// sent to user via processSystemMessage when the async task completes
|
||||||
return tools.SilentResult(response)
|
return tools.SilentResult(response)
|
||||||
})
|
})
|
||||||
|
if err := services.HeartbeatService.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("error starting heartbeat service: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println("✓ Heartbeat service started")
|
||||||
|
|
||||||
channelManager, err := channels.NewManager(cfg, msgBus)
|
// Create media store for file lifecycle management with TTL cleanup
|
||||||
|
services.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,
|
||||||
|
})
|
||||||
|
// Start the media store if it's a FileMediaStore with cleanup
|
||||||
|
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
||||||
|
fms.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create channel manager
|
||||||
|
var err error
|
||||||
|
services.ChannelManager, err = channels.NewManager(cfg, msgBus, services.MediaStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating channel manager: %w", err)
|
// Stop the media store if it's a FileMediaStore with cleanup
|
||||||
|
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
||||||
|
fms.Stop()
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("error creating channel manager: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject channel manager into agent loop for command handling
|
// Inject channel manager and media store into agent loop
|
||||||
agentLoop.SetChannelManager(channelManager)
|
agentLoop.SetChannelManager(services.ChannelManager)
|
||||||
|
agentLoop.SetMediaStore(services.MediaStore)
|
||||||
|
|
||||||
var transcriber *voice.GroqTranscriber
|
// Wire up voice transcription if a supported provider is configured.
|
||||||
groqAPIKey := cfg.Providers.Groq.APIKey
|
if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
|
||||||
if groqAPIKey == "" {
|
agentLoop.SetTranscriber(transcriber)
|
||||||
for _, mc := range cfg.ModelList {
|
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
|
||||||
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
|
|
||||||
groqAPIKey = mc.APIKey
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if groqAPIKey != "" {
|
|
||||||
transcriber = voice.NewGroqTranscriber(groqAPIKey)
|
|
||||||
logger.InfoC("voice", "Groq voice transcription enabled")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if transcriber != nil {
|
enabledChannels := services.ChannelManager.GetEnabledChannels()
|
||||||
if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
|
|
||||||
if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
|
|
||||||
tc.SetTranscriber(transcriber)
|
|
||||||
logger.InfoC("voice", "Groq transcription attached to Telegram channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if discordChannel, ok := channelManager.GetChannel("discord"); ok {
|
|
||||||
if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
|
|
||||||
dc.SetTranscriber(transcriber)
|
|
||||||
logger.InfoC("voice", "Groq transcription attached to Discord channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if slackChannel, ok := channelManager.GetChannel("slack"); ok {
|
|
||||||
if sc, ok := slackChannel.(*channels.SlackChannel); ok {
|
|
||||||
sc.SetTranscriber(transcriber)
|
|
||||||
logger.InfoC("voice", "Groq transcription attached to Slack channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enabledChannels := channelManager.GetEnabledChannels()
|
|
||||||
if len(enabledChannels) > 0 {
|
if len(enabledChannels) > 0 {
|
||||||
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("⚠ Warning: No channels enabled")
|
fmt.Println("⚠ Warning: No channels enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
// Setup shared HTTP server with health endpoints and webhook handlers
|
||||||
fmt.Println("Press Ctrl+C to stop")
|
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
services.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
services.ChannelManager.SetupHTTPServer(addr, services.HealthServer)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
if err := services.ChannelManager.StartAll(context.Background()); err != nil {
|
||||||
defer cancel()
|
return nil, fmt.Errorf("error starting channels: %w", err)
|
||||||
|
|
||||||
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("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
fmt.Printf("Error starting heartbeat service: %v\n", err)
|
|
||||||
}
|
|
||||||
fmt.Println("✓ Heartbeat service started")
|
|
||||||
|
|
||||||
|
// Setup state manager and device service
|
||||||
stateManager := state.NewManager(cfg.WorkspacePath())
|
stateManager := state.NewManager(cfg.WorkspacePath())
|
||||||
deviceService := devices.NewService(devices.Config{
|
services.DeviceService = devices.NewService(devices.Config{
|
||||||
Enabled: cfg.Devices.Enabled,
|
Enabled: cfg.Devices.Enabled,
|
||||||
MonitorUSB: cfg.Devices.MonitorUSB,
|
MonitorUSB: cfg.Devices.MonitorUSB,
|
||||||
}, stateManager)
|
}, stateManager)
|
||||||
deviceService.SetBus(msgBus)
|
services.DeviceService.SetBus(msgBus)
|
||||||
if err := deviceService.Start(ctx); err != nil {
|
if err := services.DeviceService.Start(context.Background()); err != nil {
|
||||||
fmt.Printf("Error starting device service: %v\n", err)
|
logger.ErrorCF("device", "Error starting device service", map[string]any{"error": err.Error()})
|
||||||
} else if cfg.Devices.Enabled {
|
} else if cfg.Devices.Enabled {
|
||||||
fmt.Println("✓ Device event service started")
|
fmt.Println("✓ Device event service started")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := channelManager.StartAll(ctx); err != nil {
|
return services, nil
|
||||||
fmt.Printf("Error starting channels: %v\n", err)
|
}
|
||||||
|
|
||||||
|
// stopAndCleanupServices stops all services and cleans up resources
|
||||||
|
func stopAndCleanupServices(
|
||||||
|
services *gatewayServices,
|
||||||
|
shutdownTimeout time.Duration,
|
||||||
|
) {
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
if services.ChannelManager != nil {
|
||||||
|
services.ChannelManager.StopAll(shutdownCtx)
|
||||||
}
|
}
|
||||||
|
if services.DeviceService != nil {
|
||||||
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
services.DeviceService.Stop()
|
||||||
go func() {
|
}
|
||||||
if err := healthServer.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if services.HeartbeatService != nil {
|
||||||
logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
|
services.HeartbeatService.Stop()
|
||||||
|
}
|
||||||
|
if services.CronService != nil {
|
||||||
|
services.CronService.Stop()
|
||||||
|
}
|
||||||
|
if services.MediaStore != nil {
|
||||||
|
// Stop the media store if it's a FileMediaStore with cleanup
|
||||||
|
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
||||||
|
fms.Stop()
|
||||||
}
|
}
|
||||||
}()
|
}
|
||||||
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
}
|
||||||
|
|
||||||
go agentLoop.Run(ctx)
|
// shutdownGateway performs a complete gateway shutdown
|
||||||
|
func shutdownGateway(
|
||||||
sigChan := make(chan os.Signal, 1)
|
services *gatewayServices,
|
||||||
signal.Notify(sigChan, os.Interrupt)
|
agentLoop *agent.AgentLoop,
|
||||||
<-sigChan
|
provider providers.LLMProvider,
|
||||||
|
fullShutdown bool,
|
||||||
fmt.Println("\nShutting down...")
|
) {
|
||||||
if cp, ok := provider.(providers.StatefulProvider); ok {
|
if cp, ok := provider.(providers.StatefulProvider); ok && fullShutdown {
|
||||||
cp.Close()
|
cp.Close()
|
||||||
}
|
}
|
||||||
cancel()
|
|
||||||
healthServer.Stop(context.Background())
|
stopAndCleanupServices(services, gracefulShutdownTimeout)
|
||||||
deviceService.Stop()
|
|
||||||
heartbeatService.Stop()
|
|
||||||
cronService.Stop()
|
|
||||||
agentLoop.Stop()
|
agentLoop.Stop()
|
||||||
channelManager.StopAll(ctx)
|
agentLoop.Close()
|
||||||
fmt.Println("✓ Gateway stopped")
|
|
||||||
|
logger.Info("✓ Gateway stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConfigReload handles config file reload by stopping all services,
|
||||||
|
// reloading the provider and config, and restarting services with the new config.
|
||||||
|
func handleConfigReload(
|
||||||
|
ctx context.Context,
|
||||||
|
al *agent.AgentLoop,
|
||||||
|
newCfg *config.Config,
|
||||||
|
providerRef *providers.LLMProvider,
|
||||||
|
services *gatewayServices,
|
||||||
|
msgBus *bus.MessageBus,
|
||||||
|
) error {
|
||||||
|
logger.Info("🔄 Config file changed, reloading...")
|
||||||
|
|
||||||
|
newModel := newCfg.Agents.Defaults.ModelName
|
||||||
|
if newModel == "" {
|
||||||
|
newModel = newCfg.Agents.Defaults.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof(" New model is '%s', recreating provider...", newModel)
|
||||||
|
|
||||||
|
// Stop all services before reloading
|
||||||
|
logger.Info(" Stopping all services...")
|
||||||
|
stopAndCleanupServices(services, serviceShutdownTimeout)
|
||||||
|
|
||||||
|
// Create new provider from updated config first to ensure validity
|
||||||
|
// This will use the correct API key and settings from newCfg.ModelList
|
||||||
|
newProvider, newModelID, err := providers.CreateProvider(newCfg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf(" ⚠ Error creating new provider: %v", err)
|
||||||
|
logger.Warn(" Attempting to restart services with old provider and config...")
|
||||||
|
// Try to restart services with old configuration
|
||||||
|
if restartErr := restartServices(al, services, msgBus); restartErr != nil {
|
||||||
|
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("error creating new provider: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if newModelID != "" {
|
||||||
|
newCfg.Agents.Defaults.ModelName = newModelID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the atomic reload method on AgentLoop to safely swap provider and config.
|
||||||
|
// This handles locking internally to prevent races with in-flight LLM calls
|
||||||
|
// and concurrent reads of registry/config while the swap occurs.
|
||||||
|
reloadCtx, reloadCancel := context.WithTimeout(context.Background(), providerReloadTimeout)
|
||||||
|
defer reloadCancel()
|
||||||
|
|
||||||
|
if err := al.ReloadProviderAndConfig(reloadCtx, newProvider, newCfg); err != nil {
|
||||||
|
logger.Errorf(" ⚠ Error reloading agent loop: %v", err)
|
||||||
|
// Close the newly created provider since it wasn't adopted
|
||||||
|
if cp, ok := newProvider.(providers.StatefulProvider); ok {
|
||||||
|
cp.Close()
|
||||||
|
}
|
||||||
|
logger.Warn(" Attempting to restart services with old provider and config...")
|
||||||
|
if restartErr := restartServices(al, services, msgBus); restartErr != nil {
|
||||||
|
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("error reloading agent loop: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local provider reference only after successful atomic reload
|
||||||
|
*providerRef = newProvider
|
||||||
|
|
||||||
|
// Restart all services with new config
|
||||||
|
logger.Info(" Restarting all services with new configuration...")
|
||||||
|
if err := restartServices(al, services, msgBus); err != nil {
|
||||||
|
logger.Errorf(" ⚠ Error restarting services: %v", err)
|
||||||
|
return fmt.Errorf("error restarting services: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info(" ✓ Provider, configuration, and services reloaded successfully (thread-safe)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// restartServices restarts all services after a config reload
|
||||||
|
func restartServices(
|
||||||
|
al *agent.AgentLoop,
|
||||||
|
services *gatewayServices,
|
||||||
|
msgBus *bus.MessageBus,
|
||||||
|
) error {
|
||||||
|
// Create an independent context with timeout for service restart
|
||||||
|
// This prevents cancellation from the main loop context during reload
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), serviceRestartTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Get current config from agent loop (which has been updated if this is a reload)
|
||||||
|
cfg := al.GetConfig()
|
||||||
|
|
||||||
|
// Re-create and start cron service with new config
|
||||||
|
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
||||||
|
services.CronService = setupCronTool(
|
||||||
|
al,
|
||||||
|
msgBus,
|
||||||
|
cfg.WorkspacePath(),
|
||||||
|
cfg.Agents.Defaults.RestrictToWorkspace,
|
||||||
|
execTimeout,
|
||||||
|
cfg,
|
||||||
|
)
|
||||||
|
if err := services.CronService.Start(); err != nil {
|
||||||
|
return fmt.Errorf("error restarting cron service: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println(" ✓ Cron service restarted")
|
||||||
|
|
||||||
|
// Re-create and start heartbeat service with new config
|
||||||
|
services.HeartbeatService = heartbeat.NewHeartbeatService(
|
||||||
|
cfg.WorkspacePath(),
|
||||||
|
cfg.Heartbeat.Interval,
|
||||||
|
cfg.Heartbeat.Enabled,
|
||||||
|
)
|
||||||
|
services.HeartbeatService.SetBus(msgBus)
|
||||||
|
services.HeartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||||
|
if channel == "" || chatID == "" {
|
||||||
|
channel, chatID = "cli", "direct"
|
||||||
|
}
|
||||||
|
var response string
|
||||||
|
var err error
|
||||||
|
response, err = al.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")
|
||||||
|
}
|
||||||
|
return tools.SilentResult(response)
|
||||||
|
})
|
||||||
|
if err := services.HeartbeatService.Start(); err != nil {
|
||||||
|
return fmt.Errorf("error restarting heartbeat service: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println(" ✓ Heartbeat service restarted")
|
||||||
|
|
||||||
|
// Stop the old media store before creating a new one
|
||||||
|
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
||||||
|
fms.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-create media store with new config
|
||||||
|
services.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,
|
||||||
|
})
|
||||||
|
// Start the media store if it's a FileMediaStore with cleanup
|
||||||
|
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
||||||
|
fms.Start()
|
||||||
|
}
|
||||||
|
al.SetMediaStore(services.MediaStore)
|
||||||
|
|
||||||
|
// Re-create channel manager with new config
|
||||||
|
var err error
|
||||||
|
services.ChannelManager, err = channels.NewManager(cfg, msgBus, services.MediaStore)
|
||||||
|
if err != nil {
|
||||||
|
// Stop the media store if it's a FileMediaStore with cleanup
|
||||||
|
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
||||||
|
fms.Stop()
|
||||||
|
}
|
||||||
|
return fmt.Errorf("error recreating channel manager: %w", err)
|
||||||
|
}
|
||||||
|
al.SetChannelManager(services.ChannelManager)
|
||||||
|
|
||||||
|
enabledChannels := services.ChannelManager.GetEnabledChannels()
|
||||||
|
if len(enabledChannels) > 0 {
|
||||||
|
fmt.Printf(" ✓ Channels enabled: %s\n", enabledChannels)
|
||||||
|
} else {
|
||||||
|
fmt.Println(" ⚠ Warning: No channels enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup HTTP server with new config
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
services.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
services.ChannelManager.SetupHTTPServer(addr, services.HealthServer)
|
||||||
|
|
||||||
|
if err := services.ChannelManager.StartAll(ctx); err != nil {
|
||||||
|
return fmt.Errorf("error restarting channels: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf(
|
||||||
|
" ✓ Channels restarted, health endpoints at http://%s:%d/health and ready\n",
|
||||||
|
cfg.Gateway.Host,
|
||||||
|
cfg.Gateway.Port,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Re-create device service with new config
|
||||||
|
stateManager := state.NewManager(cfg.WorkspacePath())
|
||||||
|
services.DeviceService = devices.NewService(devices.Config{
|
||||||
|
Enabled: cfg.Devices.Enabled,
|
||||||
|
MonitorUSB: cfg.Devices.MonitorUSB,
|
||||||
|
}, stateManager)
|
||||||
|
services.DeviceService.SetBus(msgBus)
|
||||||
|
if err := services.DeviceService.Start(ctx); err != nil {
|
||||||
|
logger.WarnCF("device", "Failed to restart device service", map[string]any{"error": err.Error()})
|
||||||
|
} else if cfg.Devices.Enabled {
|
||||||
|
fmt.Println(" ✓ Device event service restarted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up voice transcription with new config
|
||||||
|
transcriber := voice.DetectTranscriber(cfg)
|
||||||
|
al.SetTranscriber(transcriber) // This will set it to nil if disabled
|
||||||
|
if transcriber != nil {
|
||||||
|
logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
|
||||||
|
} else {
|
||||||
|
logger.InfoCF("voice", "Transcription disabled", nil)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setupConfigWatcherPolling sets up a simple polling-based config file watcher
|
||||||
|
// Returns a channel for config updates and a stop function
|
||||||
|
func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Config, func()) {
|
||||||
|
configChan := make(chan *config.Config, 1)
|
||||||
|
stop := make(chan struct{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Get initial file info
|
||||||
|
lastModTime := getFileModTime(configPath)
|
||||||
|
lastSize := getFileSize(configPath)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(2 * time.Second) // Check every 2 seconds
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
currentModTime := getFileModTime(configPath)
|
||||||
|
currentSize := getFileSize(configPath)
|
||||||
|
|
||||||
|
// Check if file changed (modification time or size changed)
|
||||||
|
if currentModTime.After(lastModTime) || currentSize != lastSize {
|
||||||
|
if debug {
|
||||||
|
logger.Debugf("🔍 Config file change detected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce - wait a bit to ensure file write is complete
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
// Validate and load new config
|
||||||
|
newCfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("⚠ Error loading new config: %v", err)
|
||||||
|
logger.Warn(" Using previous valid config")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the new config
|
||||||
|
if err := newCfg.ValidateModelList(); err != nil {
|
||||||
|
logger.Errorf(" ⚠ New config validation failed: %v", err)
|
||||||
|
logger.Warn(" Using previous valid config")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("✓ Config file validated and loaded")
|
||||||
|
|
||||||
|
// Update last known state
|
||||||
|
lastModTime = currentModTime
|
||||||
|
lastSize = currentSize
|
||||||
|
|
||||||
|
// Send new config to main loop (non-blocking)
|
||||||
|
select {
|
||||||
|
case configChan <- newCfg:
|
||||||
|
default:
|
||||||
|
// Channel full, skip this update
|
||||||
|
logger.Warn("⚠ Previous config reload still in progress, skipping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
stopFunc := func() {
|
||||||
|
close(stop)
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
return configChan, stopFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFileModTime returns the modification time of a file, or zero time if file doesn't exist
|
||||||
|
func getFileModTime(path string) time.Time {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return info.ModTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFileSize returns the size of a file, or 0 if file doesn't exist
|
||||||
|
func getFileSize(path string) int64 {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
func setupCronTool(
|
func setupCronTool(
|
||||||
agentLoop *agent.AgentLoop,
|
agentLoop *agent.AgentLoop,
|
||||||
msgBus *bus.MessageBus,
|
msgBus *bus.MessageBus,
|
||||||
|
|
@ -231,15 +619,25 @@ func setupCronTool(
|
||||||
// Create cron service
|
// Create cron service
|
||||||
cronService := cron.NewCronService(cronStorePath, nil)
|
cronService := cron.NewCronService(cronStorePath, nil)
|
||||||
|
|
||||||
// Create and register CronTool
|
// Create and register CronTool if enabled
|
||||||
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
var cronTool *tools.CronTool
|
||||||
agentLoop.RegisterTool(cronTool)
|
if cfg.Tools.IsToolEnabled("cron") {
|
||||||
|
var err error
|
||||||
|
cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("Critical error during CronTool initialization: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Set the onJob handler
|
agentLoop.RegisterTool(cronTool)
|
||||||
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
}
|
||||||
result := cronTool.ExecuteJob(context.Background(), job)
|
|
||||||
return result, nil
|
// Set onJob handler
|
||||||
})
|
if cronTool != nil {
|
||||||
|
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
||||||
|
result := cronTool.ExecuteJob(context.Background(), job)
|
||||||
|
return result, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return cronService
|
return cronService
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,29 @@
|
||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Logo = "🦞"
|
const Logo = "🦞"
|
||||||
|
|
||||||
var (
|
// GetPicoclawHome returns the picoclaw home directory.
|
||||||
version = "dev"
|
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||||
gitCommit string
|
func GetPicoclawHome() string {
|
||||||
buildTime string
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
goVersion string
|
return home
|
||||||
)
|
}
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".picoclaw")
|
||||||
|
}
|
||||||
|
|
||||||
func GetConfigPath() string {
|
func GetConfigPath() string {
|
||||||
home, _ := os.UserHomeDir()
|
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
|
||||||
return filepath.Join(home, ".picoclaw", "config.json")
|
return configPath
|
||||||
|
}
|
||||||
|
return filepath.Join(GetPicoclawHome(), "config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() (*config.Config, error) {
|
func LoadConfig() (*config.Config, error) {
|
||||||
|
|
@ -28,25 +31,19 @@ func LoadConfig() (*config.Config, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatVersion returns the version string with optional git commit
|
// FormatVersion returns the version string with optional git commit
|
||||||
|
// Deprecated: Use pkg/config.FormatVersion instead
|
||||||
func FormatVersion() string {
|
func FormatVersion() string {
|
||||||
v := version
|
return config.FormatVersion()
|
||||||
if gitCommit != "" {
|
|
||||||
v += fmt.Sprintf(" (git: %s)", gitCommit)
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatBuildInfo returns build time and go version info
|
// FormatBuildInfo returns build time and go version info
|
||||||
|
// Deprecated: Use pkg/config.FormatBuildInfo instead
|
||||||
func FormatBuildInfo() (string, string) {
|
func FormatBuildInfo() (string, string) {
|
||||||
build := buildTime
|
return config.FormatBuildInfo()
|
||||||
goVer := goVersion
|
|
||||||
if goVer == "" {
|
|
||||||
goVer = runtime.Version()
|
|
||||||
}
|
|
||||||
return build, goVer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVersion returns the version string
|
// GetVersion returns the version string
|
||||||
|
// Deprecated: Use pkg/config.GetVersion instead
|
||||||
func GetVersion() string {
|
func GetVersion() string {
|
||||||
return version
|
return config.GetVersion()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,63 +19,25 @@ func TestGetConfigPath(t *testing.T) {
|
||||||
assert.Equal(t, want, got)
|
assert.Equal(t, want, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
|
||||||
oldVersion, oldGit := version, gitCommit
|
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
|
||||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
version = "1.2.3"
|
got := GetConfigPath()
|
||||||
gitCommit = ""
|
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) {
|
func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
|
||||||
oldVersion, oldGit := version, gitCommit
|
t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
|
||||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
|
||||||
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
version = "1.2.3"
|
got := GetConfigPath()
|
||||||
gitCommit = "abc123"
|
want := "/custom/config.json"
|
||||||
|
|
||||||
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
|
assert.Equal(t, want, got)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetConfigPath_Windows(t *testing.T) {
|
func TestGetConfigPath_Windows(t *testing.T) {
|
||||||
|
|
@ -91,7 +53,3 @@ func TestGetConfigPath_Windows(t *testing.T) {
|
||||||
|
|
||||||
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
|
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetVersion(t *testing.T) {
|
|
||||||
assert.Equal(t, "dev", GetVersion())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -11,19 +11,21 @@ func NewMigrateCommand() *cobra.Command {
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "migrate",
|
Use: "migrate",
|
||||||
Short: "Migrate from OpenClaw to PicoClaw",
|
Short: "Migrate from xxxclaw(openclaw, etc.) to picoclaw",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
Example: ` picoclaw migrate
|
Example: ` picoclaw migrate
|
||||||
|
picoclaw migrate --from openclaw
|
||||||
picoclaw migrate --dry-run
|
picoclaw migrate --dry-run
|
||||||
picoclaw migrate --refresh
|
picoclaw migrate --refresh
|
||||||
picoclaw migrate --force`,
|
picoclaw migrate --force`,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
result, err := migrate.Run(opts)
|
m := migrate.NewMigrateInstance(opts)
|
||||||
|
result, err := m.Run(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !opts.DryRun {
|
if !opts.DryRun {
|
||||||
migrate.PrintSummary(result)
|
m.PrintSummary(result)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|
@ -31,6 +33,8 @@ func NewMigrateCommand() *cobra.Command {
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
||||||
"Show what would be migrated without making changes")
|
"Show what would be migrated without making changes")
|
||||||
|
cmd.Flags().StringVar(&opts.Source, "from", "openclaw",
|
||||||
|
"Source to migrate from (e.g., openclaw)")
|
||||||
cmd.Flags().BoolVar(&opts.Refresh, "refresh", false,
|
cmd.Flags().BoolVar(&opts.Refresh, "refresh", false,
|
||||||
"Re-sync workspace files from OpenClaw (repeatable)")
|
"Re-sync workspace files from OpenClaw (repeatable)")
|
||||||
cmd.Flags().BoolVar(&opts.ConfigOnly, "config-only", false,
|
cmd.Flags().BoolVar(&opts.ConfigOnly, "config-only", false,
|
||||||
|
|
@ -39,10 +43,10 @@ func NewMigrateCommand() *cobra.Command {
|
||||||
"Only migrate workspace files, skip config")
|
"Only migrate workspace files, skip config")
|
||||||
cmd.Flags().BoolVar(&opts.Force, "force", false,
|
cmd.Flags().BoolVar(&opts.Force, "force", false,
|
||||||
"Skip confirmation prompts")
|
"Skip confirmation prompts")
|
||||||
cmd.Flags().StringVar(&opts.OpenClawHome, "openclaw-home", "",
|
cmd.Flags().StringVar(&opts.SourceHome, "source-home", "",
|
||||||
"Override OpenClaw home directory (default: ~/.openclaw)")
|
"Override source home directory (default: ~/.openclaw)")
|
||||||
cmd.Flags().StringVar(&opts.PicoClawHome, "picoclaw-home", "",
|
cmd.Flags().StringVar(&opts.TargetHome, "target-home", "",
|
||||||
"Override PicoClaw home directory (default: ~/.picoclaw)")
|
"Override target home directory (default: ~/.picoclaw)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ func TestNewMigrateCommand(t *testing.T) {
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
assert.Equal(t, "migrate", cmd.Use)
|
assert.Equal(t, "migrate", cmd.Use)
|
||||||
assert.Equal(t, "Migrate from OpenClaw to PicoClaw", cmd.Short)
|
assert.Equal(t, "Migrate from xxxclaw(openclaw, etc.) to picoclaw", cmd.Short)
|
||||||
|
|
||||||
assert.Len(t, cmd.Aliases, 0)
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
|
|
||||||
|
|
@ -33,6 +33,6 @@ func TestNewMigrateCommand(t *testing.T) {
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("config-only"))
|
assert.NotNil(t, cmd.Flags().Lookup("config-only"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("workspace-only"))
|
assert.NotNil(t, cmd.Flags().Lookup("workspace-only"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("openclaw-home"))
|
assert.NotNil(t, cmd.Flags().Lookup("source-home"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("picoclaw-home"))
|
assert.NotNil(t, cmd.Flags().Lookup("target-home"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
138
cmd/picoclaw/internal/model/command.go
Normal file
138
cmd/picoclaw/internal/model/command.go
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
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 == "" {
|
||||||
|
defaultModel = cfg.Agents.Defaults.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
if defaultModel == "" {
|
||||||
|
defaultModel = cfg.Agents.Defaults.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
if oldModel == "" {
|
||||||
|
oldModel = cfg.Agents.Defaults.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Agents.Defaults.ModelName = modelName
|
||||||
|
cfg.Agents.Defaults.Model = "" // Clear deprecated field
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
369
cmd/picoclaw/internal/model/command_test.go
Normal file
369
cmd/picoclaw/internal/model/command_test.go
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
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", APIKey: "test"},
|
||||||
|
{ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "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: "",
|
||||||
|
Model: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "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 TestShowCurrentModel_BackwardCompatibility(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Model: "legacy-model",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []config.ModelConfig{},
|
||||||
|
}
|
||||||
|
|
||||||
|
output := captureStdout(func() {
|
||||||
|
showCurrentModel(cfg)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, output, "Current default model: legacy-model")
|
||||||
|
}
|
||||||
|
|
||||||
|
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", APIKey: "test"},
|
||||||
|
{ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
|
||||||
|
{ModelName: "no-key-model", Model: "openai/test", APIKey: ""},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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", APIKey: "test"},
|
||||||
|
{ModelName: "old-model", Model: "openai/old-model", APIKey: "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)
|
||||||
|
assert.Empty(t, updatedCfg.Agents.Defaults.Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetDefaultModel_LegacyModelField(t *testing.T) {
|
||||||
|
initTest(t)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Model: "legacy-old",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
output := captureStdout(func() {
|
||||||
|
err := setDefaultModel(configPath, cfg, "new-model")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, output, "Default model changed from 'legacy-old' to 'new-model'")
|
||||||
|
}
|
||||||
|
|
||||||
|
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", APIKey: "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", APIKey: "test"},
|
||||||
|
{ModelName: "no-key-model", Model: "openai/nokey", APIKey: ""},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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", APIKey: "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", APIKey: "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)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
ModelName: "old-model",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "old-model", Model: "openai/old", APIKey: "test"},
|
||||||
|
{ModelName: "new-model", Model: "openai/new", APIKey: "test"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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", APIKey: "test"},
|
||||||
|
{ModelName: "middle-model", Model: "openai/middle", APIKey: "test"},
|
||||||
|
{ModelName: "last-model", Model: "openai/last", APIKey: "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)")
|
||||||
|
}
|
||||||
25
cmd/picoclaw/internal/onboard/helpers_test.go
Normal file
25
cmd/picoclaw/internal/onboard/helpers_test.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package onboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCopyEmbeddedToTargetUsesAgentsMarkdown(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
legacyPath := filepath.Join(targetDir, "AGENT.md")
|
||||||
|
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.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
|
// get global config directory and builtin skills directory
|
||||||
globalDir := filepath.Dir(internal.GetConfigPath())
|
globalDir := filepath.Dir(internal.GetConfigPath())
|
||||||
|
|
@ -71,7 +79,7 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
newInstallBuiltinCommand(workspaceFn),
|
newInstallBuiltinCommand(workspaceFn),
|
||||||
newListBuiltinCommand(),
|
newListBuiltinCommand(),
|
||||||
newRemoveCommand(installerFn),
|
newRemoveCommand(installerFn),
|
||||||
newSearchCommand(installerFn),
|
newSearchCommand(),
|
||||||
newShowCommand(loaderFn),
|
newShowCommand(loaderFn),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const skillsSearchMaxResults = 20
|
||||||
|
|
||||||
func skillsListCmd(loader *skills.SkillsLoader) {
|
func skillsListCmd(loader *skills.SkillsLoader) {
|
||||||
allSkills := loader.ListSkills()
|
allSkills := loader.ListSkills()
|
||||||
|
|
||||||
|
|
@ -215,34 +217,43 @@ func skillsListBuiltinCmd() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsSearchCmd(installer *skills.SkillInstaller) {
|
func skillsSearchCmd(query string) {
|
||||||
fmt.Println("Searching for available skills...")
|
fmt.Println("Searching for available skills...")
|
||||||
|
|
||||||
|
cfg, err := internal.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("✗ Failed to load config: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||||
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
|
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
||||||
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
availableSkills, err := installer.ListAvailableSkills(ctx)
|
results, err := registryMgr.SearchAll(ctx, query, skillsSearchMaxResults)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
|
fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(availableSkills) == 0 {
|
if len(results) == 0 {
|
||||||
fmt.Println("No skills available.")
|
fmt.Println("No skills available.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills))
|
fmt.Printf("\nAvailable Skills (%d):\n", len(results))
|
||||||
fmt.Println("--------------------")
|
fmt.Println("--------------------")
|
||||||
for _, skill := range availableSkills {
|
for _, result := range results {
|
||||||
fmt.Printf(" 📦 %s\n", skill.Name)
|
fmt.Printf(" 📦 %s\n", result.DisplayName)
|
||||||
fmt.Printf(" %s\n", skill.Description)
|
fmt.Printf(" %s\n", result.Summary)
|
||||||
fmt.Printf(" Repo: %s\n", skill.Repository)
|
fmt.Printf(" Slug: %s\n", result.Slug)
|
||||||
if skill.Author != "" {
|
fmt.Printf(" Registry: %s\n", result.RegistryName)
|
||||||
fmt.Printf(" Author: %s\n", skill.Author)
|
if result.Version != "" {
|
||||||
}
|
fmt.Printf(" Version: %s\n", result.Version)
|
||||||
if len(skill.Tags) > 0 {
|
|
||||||
fmt.Printf(" Tags: %v\n", skill.Tags)
|
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ picoclaw skills install --registry clawhub github
|
||||||
`,
|
`,
|
||||||
Args: func(cmd *cobra.Command, args []string) error {
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
if registry != "" {
|
if registry != "" {
|
||||||
if len(args) != 2 {
|
if len(args) != 1 {
|
||||||
return fmt.Errorf("when --registry is set, exactly 2 arguments are required: <name> <slug>")
|
return fmt.Errorf("when --registry is set, exactly 1 argument is required: <slug>")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +45,7 @@ picoclaw skills install --registry clawhub github
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return skillsInstallFromRegistry(cfg, args[0], args[1])
|
return skillsInstallFromRegistry(cfg, registry, args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
return skillsInstallCmd(installer, args[0])
|
return skillsInstallCmd(installer, args[0])
|
||||||
|
|
|
||||||
|
|
@ -26,3 +26,72 @@ func TestNewInstallSubcommand(t *testing.T) {
|
||||||
|
|
||||||
assert.Len(t, cmd.Aliases, 0)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,19 @@ package skills
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newSearchCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
func newSearchCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "search",
|
Use: "search [query]",
|
||||||
Short: "Search available skills",
|
Short: "Search available skills",
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
Args: cobra.MaximumNArgs(1),
|
||||||
installer, err := installerFn()
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
if err != nil {
|
query := ""
|
||||||
return err
|
if len(args) == 1 {
|
||||||
|
query = args[0]
|
||||||
}
|
}
|
||||||
skillsSearchCmd(installer)
|
skillsSearchCmd(query)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewSearchSubcommand(t *testing.T) {
|
func TestNewSearchSubcommand(t *testing.T) {
|
||||||
cmd := newSearchCommand(nil)
|
cmd := newSearchCommand()
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
assert.Equal(t, "search", cmd.Use)
|
assert.Equal(t, "search [query]", cmd.Use)
|
||||||
assert.Equal(t, "Search available skills", cmd.Short)
|
assert.Equal(t, "Search available skills", cmd.Short)
|
||||||
|
|
||||||
assert.Nil(t, cmd.Run)
|
assert.Nil(t, cmd.Run)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func statusCmd() {
|
func statusCmd() {
|
||||||
|
|
@ -18,8 +19,8 @@ func statusCmd() {
|
||||||
configPath := internal.GetConfigPath()
|
configPath := internal.GetConfigPath()
|
||||||
|
|
||||||
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
||||||
fmt.Printf("Version: %s\n", internal.FormatVersion())
|
fmt.Printf("Version: %s\n", config.FormatVersion())
|
||||||
build, _ := internal.FormatBuildInfo()
|
build, _ := config.FormatBuildInfo()
|
||||||
if build != "" {
|
if build != "" {
|
||||||
fmt.Printf("Build: %s\n", build)
|
fmt.Printf("Build: %s\n", build)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewVersionCommand() *cobra.Command {
|
func NewVersionCommand() *cobra.Command {
|
||||||
|
|
@ -22,8 +23,8 @@ func NewVersionCommand() *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
func printVersion() {
|
func printVersion() {
|
||||||
fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion())
|
fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion())
|
||||||
build, goVer := internal.FormatBuildInfo()
|
build, goVer := config.FormatBuildInfo()
|
||||||
if build != "" {
|
if build != "" {
|
||||||
fmt.Printf(" Build: %s\n", 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/cron"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
|
"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/onboard"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewPicoclawCommand() *cobra.Command {
|
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{
|
cmd := &cobra.Command{
|
||||||
Use: "picoclaw",
|
Use: "picoclaw",
|
||||||
Short: short,
|
Short: short,
|
||||||
Example: "picoclaw list",
|
Example: "picoclaw version",
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
|
|
@ -42,13 +44,28 @@ func NewPicoclawCommand() *cobra.Command {
|
||||||
cron.NewCronCommand(),
|
cron.NewCronCommand(),
|
||||||
migrate.NewMigrateCommand(),
|
migrate.NewMigrateCommand(),
|
||||||
skills.NewSkillsCommand(),
|
skills.NewSkillsCommand(),
|
||||||
|
model.NewModelCommand(),
|
||||||
version.NewVersionCommand(),
|
version.NewVersionCommand(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
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() {
|
func main() {
|
||||||
|
fmt.Printf("%s", banner)
|
||||||
cmd := NewPicoclawCommand()
|
cmd := NewPicoclawCommand()
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewPicoclawCommand(t *testing.T) {
|
func TestNewPicoclawCommand(t *testing.T) {
|
||||||
|
|
@ -16,7 +17,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
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, "picoclaw", cmd.Use)
|
||||||
assert.Equal(t, short, cmd.Short)
|
assert.Equal(t, short, cmd.Short)
|
||||||
|
|
@ -38,6 +39,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||||
"cron",
|
"cron",
|
||||||
"gateway",
|
"gateway",
|
||||||
"migrate",
|
"migrate",
|
||||||
|
"model",
|
||||||
"onboard",
|
"onboard",
|
||||||
"skills",
|
"skills",
|
||||||
"status",
|
"status",
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,18 @@
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"restrict_to_workspace": true,
|
"restrict_to_workspace": true,
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt-5.4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20,
|
||||||
|
"summarize_message_threshold": 20,
|
||||||
|
"summarize_token_percent": 75
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
},
|
},
|
||||||
|
|
@ -20,7 +22,15 @@
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key",
|
"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",
|
"model_name": "gemini",
|
||||||
|
|
@ -33,14 +43,25 @@
|
||||||
"api_key": "sk-your-deepseek-key"
|
"api_key": "sk-your-deepseek-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "loadbalanced-gpt4",
|
"model_name": "longcat",
|
||||||
"model": "openai/gpt-5.2",
|
"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": "loadbalanced-gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key1",
|
"api_key": "sk-key1",
|
||||||
"api_base": "https://api1.example.com/v1"
|
"api_base": "https://api1.example.com/v1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "loadbalanced-gpt4",
|
"model_name": "loadbalanced-gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key2",
|
"api_key": "sk-key2",
|
||||||
"api_base": "https://api2.example.com/v1"
|
"api_base": "https://api2.example.com/v1"
|
||||||
}
|
}
|
||||||
|
|
@ -49,33 +70,44 @@
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||||
|
"base_url": "",
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
"allow_from": [
|
"allow_from": [
|
||||||
"YOUR_USER_ID"
|
"YOUR_USER_ID"
|
||||||
]
|
],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "YOUR_DISCORD_BOT_TOKEN",
|
"token": "YOUR_DISCORD_BOT_TOKEN",
|
||||||
|
"proxy": "",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"mention_only": false
|
"group_trigger": {
|
||||||
|
"mention_only": false
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"qq": {
|
"qq": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"app_id": "YOUR_QQ_APP_ID",
|
"app_id": "YOUR_QQ_APP_ID",
|
||||||
"app_secret": "YOUR_QQ_APP_SECRET",
|
"app_secret": "YOUR_QQ_APP_SECRET",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"maixcam": {
|
"maixcam": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 18790,
|
"port": 18790,
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"bridge_url": "ws://localhost:3001",
|
"bridge_url": "ws://localhost:3001",
|
||||||
"allow_from": []
|
"use_native": false,
|
||||||
|
"session_store_path": "",
|
||||||
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"feishu": {
|
"feishu": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
@ -83,28 +115,48 @@
|
||||||
"app_secret": "",
|
"app_secret": "",
|
||||||
"encrypt_key": "",
|
"encrypt_key": "",
|
||||||
"verification_token": "",
|
"verification_token": "",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": "",
|
||||||
|
"random_reaction_emoji": []
|
||||||
},
|
},
|
||||||
"dingtalk": {
|
"dingtalk": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"client_id": "YOUR_CLIENT_ID",
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
"client_secret": "YOUR_CLIENT_SECRET",
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"slack": {
|
"slack": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
||||||
"app_token": "xapp-YOUR-APP-TOKEN",
|
"app_token": "xapp-YOUR-APP-TOKEN",
|
||||||
"allow_from": []
|
"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": ""
|
||||||
},
|
},
|
||||||
"line": {
|
"line": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18791,
|
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"onebot": {
|
"onebot": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
@ -112,33 +164,69 @@
|
||||||
"access_token": "",
|
"access_token": "",
|
||||||
"reconnect_interval": 5,
|
"reconnect_interval": 5,
|
||||||
"group_trigger_prefix": [],
|
"group_trigger_prefix": [],
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
|
"_comment": "WeCom Bot - Easier setup, supports group chats",
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18793,
|
|
||||||
"webhook_path": "/webhook/wecom",
|
"webhook_path": "/webhook/wecom",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reply_timeout": 5
|
"reply_timeout": 5,
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"wecom_app": {
|
"wecom_app": {
|
||||||
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
|
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only.",
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"corp_id": "YOUR_CORP_ID",
|
"corp_id": "YOUR_CORP_ID",
|
||||||
"corp_secret": "YOUR_CORP_SECRET",
|
"corp_secret": "YOUR_CORP_SECRET",
|
||||||
"agent_id": 1000002,
|
"agent_id": 1000002,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
"webhook_path": "/webhook/wecom-app",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reply_timeout": 5
|
"reply_timeout": 5,
|
||||||
|
"reasoning_channel_id": ""
|
||||||
|
},
|
||||||
|
"wecom_aibot": {
|
||||||
|
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
|
||||||
|
"enabled": false,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
"max_steps": 10,
|
||||||
|
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
|
||||||
|
"reasoning_channel_id": ""
|
||||||
|
},
|
||||||
|
"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": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"typing": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
@ -200,15 +288,39 @@
|
||||||
"mistral": {
|
"mistral": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"api_base": "https://api.mistral.ai/v1"
|
"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": {
|
"tools": {
|
||||||
|
"allow_read_paths": null,
|
||||||
|
"allow_write_paths": null,
|
||||||
"web": {
|
"web": {
|
||||||
|
"enabled": true,
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
"api_keys": [
|
||||||
|
"YOUR_BRAVE_API_KEY"
|
||||||
|
],
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
|
"tavily": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "",
|
||||||
|
"base_url": "",
|
||||||
|
"max_results": 0
|
||||||
|
},
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
|
@ -216,27 +328,175 @@
|
||||||
"perplexity": {
|
"perplexity": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "pplx-xxx",
|
"api_key": "pplx-xxx",
|
||||||
|
"api_keys": [
|
||||||
|
"pplx-xxx"
|
||||||
|
],
|
||||||
"max_results": 5
|
"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
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
|
"enabled": true,
|
||||||
"exec_timeout_minutes": 5
|
"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": {
|
"exec": {
|
||||||
"enable_deny_patterns": false,
|
"enabled": true,
|
||||||
"custom_deny_patterns": []
|
"enable_deny_patterns": true,
|
||||||
|
"custom_deny_patterns": null,
|
||||||
|
"custom_allow_patterns": null
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
|
"enabled": true,
|
||||||
"registries": {
|
"registries": {
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"base_url": "https://clawhub.ai",
|
"base_url": "https://clawhub.ai",
|
||||||
"search_path": "/api/v1/search",
|
"auth_token": "",
|
||||||
"skills_path": "/api/v1/skills",
|
"search_path": "",
|
||||||
"download_path": "/api/v1/download"
|
"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": {
|
"heartbeat": {
|
||||||
|
|
@ -247,6 +507,9 @@
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"monitor_usb": true
|
"monitor_usb": true
|
||||||
},
|
},
|
||||||
|
"voice": {
|
||||||
|
"echo_transcription": false
|
||||||
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 18790
|
"port": 18790
|
||||||
|
|
|
||||||
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"]
|
||||||
|
|
@ -5,6 +5,8 @@ ARG TARGETPLATFORM
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["picoclaw"]
|
RUN chmod +x /entrypoint.sh
|
||||||
CMD ["gateway"]
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
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"]
|
||||||
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
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
services:
|
services:
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# PicoClaw Agent (one-shot query)
|
# PicoClaw Agent (one-shot query)
|
||||||
# docker compose run --rm picoclaw-agent -m "Hello"
|
# docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello"
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-agent:
|
picoclaw-agent:
|
||||||
build:
|
image: docker.io/sipeed/picoclaw:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: picoclaw-agent
|
container_name: picoclaw-agent
|
||||||
profiles:
|
profiles:
|
||||||
- agent
|
- agent
|
||||||
|
|
@ -14,33 +12,41 @@ services:
|
||||||
#extra_hosts:
|
#extra_hosts:
|
||||||
# - "host.docker.internal:host-gateway"
|
# - "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
- ./data:/root/.picoclaw
|
||||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
|
||||||
entrypoint: ["picoclaw", "agent"]
|
entrypoint: ["picoclaw", "agent"]
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# PicoClaw Gateway (Long-running Bot)
|
# PicoClaw Gateway (Long-running Bot)
|
||||||
# docker compose up picoclaw-gateway
|
# docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-gateway:
|
picoclaw-gateway:
|
||||||
build:
|
image: docker.io/sipeed/picoclaw:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: picoclaw-gateway
|
container_name: picoclaw-gateway
|
||||||
restart: unless-stopped
|
restart: on-failure
|
||||||
profiles:
|
profiles:
|
||||||
- gateway
|
- gateway
|
||||||
# Uncomment to access host network; leave commented unless needed.
|
# Uncomment to access host network; leave commented unless needed.
|
||||||
#extra_hosts:
|
#extra_hosts:
|
||||||
# - "host.docker.internal:host-gateway"
|
# - "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
# Configuration file
|
- ./data:/root/.picoclaw
|
||||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
|
||||||
# Persistent workspace (sessions, memory, logs)
|
|
||||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
|
||||||
command: ["gateway"]
|
|
||||||
|
|
||||||
volumes:
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-workspace:
|
# 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
|
||||||
15
docker/entrypoint.sh
Normal file
15
docker/entrypoint.sh
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# First-run: neither config nor workspace exists.
|
||||||
|
# If config.json is already mounted but workspace is missing we skip onboard to
|
||||||
|
# avoid the interactive "Overwrite? (y/n)" prompt hanging in a non-TTY container.
|
||||||
|
if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.json" ]; then
|
||||||
|
picoclaw onboard
|
||||||
|
echo ""
|
||||||
|
echo "First-run setup complete."
|
||||||
|
echo "Edit ${HOME}/.picoclaw/config.json (add your API key, etc.) then restart the container."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec picoclaw gateway "$@"
|
||||||
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
|
||||||
|
|
@ -11,7 +11,9 @@ Discord 是一个专为社区设计的免费语音、视频和文本聊天应用
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
"mention_only": false
|
"group_trigger": {
|
||||||
|
"mention_only": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -22,7 +24,7 @@ Discord 是一个专为社区设计的免费语音、视频和文本聊天应用
|
||||||
| enabled | bool | 是 | 是否启用 Discord 频道 |
|
| enabled | bool | 是 | 是否启用 Discord 频道 |
|
||||||
| token | string | 是 | Discord 机器人 Token |
|
| token | string | 是 | Discord 机器人 Token |
|
||||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||||
| mention_only | bool | 否 | 是否仅响应提及机器人的消息 |
|
| group_trigger | object | 否 | 群组触发设置(示例: { "mention_only": false }) |
|
||||||
|
|
||||||
## 设置流程
|
## 设置流程
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@
|
||||||
| app_secret | string | 是 | 飞书应用的 App Secret |
|
| app_secret | string | 是 | 飞书应用的 App Secret |
|
||||||
| encrypt_key | string | 否 | 事件回调加密密钥 |
|
| encrypt_key | string | 否 | 事件回调加密密钥 |
|
||||||
| verification_token | string | 否 | 用于Webhook事件验证的Token |
|
| verification_token | string | 否 | 用于Webhook事件验证的Token |
|
||||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
| allow_from | array | 否 | 用户ID白名单,空表示所有用户 |
|
||||||
|
| random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" |
|
||||||
|
|
||||||
## 设置流程
|
## 设置流程
|
||||||
|
|
||||||
|
|
@ -35,3 +36,4 @@
|
||||||
3. 配置事件订阅和Webhook URL
|
3. 配置事件订阅和Webhook URL
|
||||||
4. 设置加密(可选,生产环境建议启用)
|
4. 设置加密(可选,生产环境建议启用)
|
||||||
5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
|
5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
|
||||||
|
6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce))
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18791,
|
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -25,9 +23,7 @@ PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的
|
||||||
| enabled | bool | 是 | 是否启用 LINE Channel |
|
| enabled | bool | 是 | 是否启用 LINE Channel |
|
||||||
| channel_secret | string | 是 | LINE Messaging API 的 Channel Secret |
|
| channel_secret | string | 是 | LINE Messaging API 的 Channel Secret |
|
||||||
| channel_access_token | string | 是 | LINE Messaging API 的 Channel Access Token |
|
| channel_access_token | string | 是 | LINE Messaging API 的 Channel Access Token |
|
||||||
| webhook_host | string | 是 | Webhook 监听的主机地址 (通常为 0.0.0.0) |
|
| webhook_path | string | 否 | Webhook 的路径 (默认为 /webhook/line) |
|
||||||
| webhook_port | int | 是 | Webhook 监听的端口 (默认为 18791) |
|
|
||||||
| webhook_path | string | 是 | Webhook 的路径 (默认为 /webhook/line) |
|
|
||||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||||
|
|
||||||
## 设置流程
|
## 设置流程
|
||||||
|
|
@ -35,7 +31,8 @@ PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的
|
||||||
1. 前往 [LINE Developers Console](https://developers.line.biz/console/) 创建一个服务提供商和一个 Messaging API Channel
|
1. 前往 [LINE Developers Console](https://developers.line.biz/console/) 创建一个服务提供商和一个 Messaging API Channel
|
||||||
2. 获取 Channel Secret 和 Channel Access Token
|
2. 获取 Channel Secret 和 Channel Access Token
|
||||||
3. 配置Webhook:
|
3. 配置Webhook:
|
||||||
- Line要求Webhook必须使用HTTPS协议,因此需要部署一个支持HTTPS的服务器,或者使用反向代理工具如ngrok将本地服务器暴露到公网
|
- LINE 要求 Webhook 必须使用 HTTPS 协议,因此需要部署一个支持 HTTPS 的服务器,或者使用反向代理工具如 ngrok 将本地服务器暴露到公网
|
||||||
- 将 Webhook URL 设置为 `https://your-domain.com/webhook/line`
|
- PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790
|
||||||
|
- 将 Webhook URL 设置为 `https://your-domain.com/webhook/line`,然后将外部域名反向代理到本机的 Gateway(默认端口 18790)
|
||||||
- 启用 Webhook 并验证 URL
|
- 启用 Webhook 并验证 URL
|
||||||
4. 将 Channel Secret 和 Channel Access Token 填入配置文件中
|
4. 将 Channel Secret 和 Channel Access Token 填入配置文件中
|
||||||
|
|
|
||||||
62
docs/channels/matrix/README.md
Normal file
62
docs/channels/matrix/README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Matrix Channel Configuration Guide
|
||||||
|
|
||||||
|
## 1. Example Configuration
|
||||||
|
|
||||||
|
Add this to `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"matrix": {
|
||||||
|
"enabled": true,
|
||||||
|
"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": "",
|
||||||
|
"message_format": "richtext"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Field Reference
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|----------------------|----------|----------|-------------|
|
||||||
|
| enabled | bool | Yes | Enable or disable the Matrix channel |
|
||||||
|
| homeserver | string | Yes | Matrix homeserver URL (for example `https://matrix.org`) |
|
||||||
|
| user_id | string | Yes | Bot Matrix user ID (for example `@bot:matrix.org`) |
|
||||||
|
| access_token | string | Yes | Bot access token |
|
||||||
|
| device_id | string | No | Optional Matrix device ID |
|
||||||
|
| join_on_invite | bool | No | Auto-join invited rooms |
|
||||||
|
| allow_from | []string | No | User whitelist (Matrix user IDs) |
|
||||||
|
| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
|
||||||
|
| placeholder | object | No | Placeholder message config |
|
||||||
|
| reasoning_channel_id | string | No | Target channel for reasoning output |
|
||||||
|
| message_format | string | No | Output format: `"richtext"` (default) renders markdown as HTML; `"plain"` sends plain text only |
|
||||||
|
|
||||||
|
## 3. Currently Supported
|
||||||
|
|
||||||
|
- Text message send/receive with markdown rendering (bold, italic, headers, code blocks, etc.)
|
||||||
|
- Configurable message format (`richtext` / `plain`)
|
||||||
|
- Incoming image/audio/video/file download (MediaStore first, local path fallback)
|
||||||
|
- Incoming audio normalization into existing transcription flow (`[audio: ...]`)
|
||||||
|
- Outgoing image/audio/video/file upload and send
|
||||||
|
- Group trigger rules (including mention-only mode)
|
||||||
|
- Typing state (`m.typing`)
|
||||||
|
- Placeholder message + final reply replacement
|
||||||
|
- Auto-join invited rooms (can be disabled)
|
||||||
|
|
||||||
|
## 4. TODO
|
||||||
|
|
||||||
|
- Rich media metadata improvements (for example image/video size and thumbnails)
|
||||||
59
docs/channels/matrix/README.zh.md
Normal file
59
docs/channels/matrix/README.zh.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Matrix 通道配置指南
|
||||||
|
|
||||||
|
## 1. 配置示例
|
||||||
|
|
||||||
|
在 `config.json` 中添加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"matrix": {
|
||||||
|
"enabled": true,
|
||||||
|
"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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|----------------------|----------|------|------|
|
||||||
|
| enabled | bool | 是 | 是否启用 Matrix 通道 |
|
||||||
|
| homeserver | string | 是 | Matrix 服务器地址(例如 `https://matrix.org`) |
|
||||||
|
| user_id | string | 是 | 机器人 Matrix 用户 ID(例如 `@bot:matrix.org`) |
|
||||||
|
| access_token | string | 是 | 机器人 access token |
|
||||||
|
| device_id | string | 否 | 设备 ID(可选) |
|
||||||
|
| join_on_invite | bool | 否 | 是否自动加入邀请房间 |
|
||||||
|
| allow_from | []string | 否 | 白名单用户(Matrix 用户 ID) |
|
||||||
|
| group_trigger | object | 否 | 群聊触发策略(支持 `mention_only` / `prefixes`) |
|
||||||
|
| placeholder | object | 否 | 占位消息配置 |
|
||||||
|
| reasoning_channel_id | string | 否 | 思维链输出目标通道 |
|
||||||
|
|
||||||
|
## 3. 当前支持
|
||||||
|
|
||||||
|
- 文本消息收发
|
||||||
|
- 图片/音频/视频/文件消息入站下载(写入 MediaStore / 本地路径回退)
|
||||||
|
- 音频消息按统一标记进入现有转写流程(`[audio: ...]`)
|
||||||
|
- 图片/音频/视频/文件消息出站发送(上传到 Matrix 媒体库后发送)
|
||||||
|
- 群聊触发规则(支持仅 @ 提及时响应)
|
||||||
|
- Typing 状态(`m.typing`)
|
||||||
|
- 占位消息(`Thinking... 💭`)+ 最终回复替换
|
||||||
|
- 自动加入邀请房间(可关闭)
|
||||||
|
|
||||||
|
## 4. TODO
|
||||||
|
|
||||||
|
- 富媒体细节增强(如 image/video 的尺寸、缩略图等 metadata)
|
||||||
116
docs/channels/wecom/wecom_aibot/README.zh.md
Normal file
116
docs/channels/wecom/wecom_aibot/README.zh.md
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
# 企业微信智能机器人 (AI Bot)
|
||||||
|
|
||||||
|
企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议,并支持超时后通过 `response_url` 主动推送最终回复。
|
||||||
|
|
||||||
|
## 与其他 WeCom 通道的对比
|
||||||
|
|
||||||
|
| 特性 | WeCom Bot | WeCom App | **WeCom AI Bot** |
|
||||||
|
|------|-----------|-----------|-----------------|
|
||||||
|
| 私聊 | ✅ | ✅ | ✅ |
|
||||||
|
| 群聊 | ✅ | ❌ | ✅ |
|
||||||
|
| 流式输出 | ❌ | ❌ | ✅ |
|
||||||
|
| 超时主动推送 | ❌ | ✅ | ✅ |
|
||||||
|
| 配置复杂度 | 低 | 高 | 中 |
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_aibot": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
"allow_from": [],
|
||||||
|
"welcome_message": "你好!有什么可以帮助你的吗?",
|
||||||
|
"max_steps": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 描述 |
|
||||||
|
| ---------------- | ------ | ---- | -------------------------------------------------- |
|
||||||
|
| token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 |
|
||||||
|
| encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 |
|
||||||
|
| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-aibot) |
|
||||||
|
| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 |
|
||||||
|
| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 |
|
||||||
|
| reply_timeout | int | 否 | 回复超时时间(秒,默认:5) |
|
||||||
|
| max_steps | int | 否 | Agent 最大执行步骤数(默认:10) |
|
||||||
|
|
||||||
|
## 设置流程
|
||||||
|
|
||||||
|
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
|
||||||
|
2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot
|
||||||
|
3. 在 AI Bot 配置页面,填写"消息接收"信息:
|
||||||
|
- **URL**:`http://<your-server-ip>:18791/webhook/wecom-aibot`
|
||||||
|
- **Token**:随机生成或自定义
|
||||||
|
- **EncodingAESKey**:点击"随机生成",得到 43 字符密钥
|
||||||
|
4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件,启动服务后回到管理后台保存(企业微信会发送验证请求)
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 服务器需要能被企业微信服务器访问。如在内网/本地开发,可使用 [ngrok](https://ngrok.com) 或 frp 做内网穿透。
|
||||||
|
|
||||||
|
## 流式响应协议
|
||||||
|
|
||||||
|
WeCom AI Bot 使用"流式拉取"协议,区别于普通 Webhook 的一次性回复:
|
||||||
|
|
||||||
|
```
|
||||||
|
用户发消息
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
PicoClaw 立即返回 {finish: false}(Agent 开始处理)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
企业微信每隔约 1 秒拉取一次 {msgtype: "stream", stream: {id: "..."}}
|
||||||
|
│
|
||||||
|
├─ Agent 未完成 → 返回 {finish: false}(继续等待)
|
||||||
|
│
|
||||||
|
└─ Agent 完成 → 返回 {finish: true, content: "回答内容"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**超时处理**(任务超过 30 秒):
|
||||||
|
|
||||||
|
若 Agent 处理时间超过约 30 秒(企业微信最大轮询窗口为 6 分钟),PicoClaw 会:
|
||||||
|
|
||||||
|
1. 立即关闭流,向用户显示「⏳ 正在处理中,请稍候,结果将稍后发送。」
|
||||||
|
2. Agent 继续在后台运行
|
||||||
|
3. Agent 完成后,通过消息中携带的 `response_url` 将最终回复主动推送给用户
|
||||||
|
|
||||||
|
> `response_url` 由企业微信颁发,有效期 1 小时,只可使用一次,无需加密,直接 POST markdown 消息体即可。
|
||||||
|
|
||||||
|
## 欢迎语
|
||||||
|
|
||||||
|
配置 `welcome_message` 后,当用户打开与 AI Bot 的聊天窗口时(`enter_chat` 事件),PicoClaw 会自动回复该欢迎语。留空则静默忽略。
|
||||||
|
|
||||||
|
```json
|
||||||
|
"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 回调 URL 验证失败
|
||||||
|
|
||||||
|
- 确认服务器防火墙已开放对应端口(默认 18791)
|
||||||
|
- 确认 `token` 与 `encoding_aes_key` 填写正确
|
||||||
|
- 检查 PicoClaw 日志是否收到了来自企业微信的 GET 请求
|
||||||
|
|
||||||
|
### 消息没有回复
|
||||||
|
|
||||||
|
- 检查 `allow_from` 是否意外限制了发送者
|
||||||
|
- 查看日志中是否出现 `context canceled` 或 Agent 错误
|
||||||
|
- 确认 Agent 配置(`model_name` 等)正确
|
||||||
|
|
||||||
|
### 超长任务没有收到最终推送
|
||||||
|
|
||||||
|
- 确认消息回调中携带了 `response_url`(仅企业微信新版 AI Bot 支持)
|
||||||
|
- 确认服务器能主动访问外网(需向 `response_url` POST 请求)
|
||||||
|
- 查看日志关键词 `response_url mode` 和 `Sending reply via response_url`
|
||||||
|
|
||||||
|
## 参考文档
|
||||||
|
|
||||||
|
- [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/100719)
|
||||||
|
- [流式响应协议说明](https://developer.work.weixin.qq.com/document/path/100719)
|
||||||
|
- [response_url 主动回复](https://developer.work.weixin.qq.com/document/path/101138)
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
"agent_id": 1000002,
|
"agent_id": 1000002,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
"webhook_path": "/webhook/wecom-app",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reply_timeout": 5
|
"reply_timeout": 5
|
||||||
|
|
@ -31,8 +29,6 @@
|
||||||
| agent_id | int | 是 | 应用程序代理 ID |
|
| agent_id | int | 是 | 应用程序代理 ID |
|
||||||
| token | string | 是 | 回调验证令牌 |
|
| token | string | 是 | 回调验证令牌 |
|
||||||
| encoding_aes_key | string | 是 | 43 字符 AES 密钥 |
|
| encoding_aes_key | string | 是 | 43 字符 AES 密钥 |
|
||||||
| webhook_host | string | 否 | HTTP 服务器绑定地址 |
|
|
||||||
| webhook_port | int | 否 | HTTP 服务器端口(默认:18792) |
|
|
||||||
| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-app) |
|
| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-app) |
|
||||||
| allow_from | array | 否 | 用户 ID 白名单 |
|
| allow_from | array | 否 | 用户 ID 白名单 |
|
||||||
| reply_timeout | int | 否 | 回复超时时间(秒) |
|
| reply_timeout | int | 否 | 回复超时时间(秒) |
|
||||||
|
|
@ -45,3 +41,5 @@
|
||||||
4. 在应用设置中配置“接收消息”,获取 Token 和 EncodingAESKey
|
4. 在应用设置中配置“接收消息”,获取 Token 和 EncodingAESKey
|
||||||
5. 设置回调 URL 为 `http://<your-server-ip>:<port>/webhook/wecom-app`
|
5. 设置回调 URL 为 `http://<your-server-ip>:<port>/webhook/wecom-app`
|
||||||
6. 将 CorpID, Secret, AgentID 等信息填入配置文件
|
6. 将 CorpID, Secret, AgentID 等信息填入配置文件
|
||||||
|
|
||||||
|
注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790。如需从公网接收回调,请把外部域名反向代理到 Gateway(默认端口 18790)。
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18793,
|
|
||||||
"webhook_path": "/webhook/wecom",
|
"webhook_path": "/webhook/wecom",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reply_timeout": 5
|
"reply_timeout": 5
|
||||||
|
|
@ -27,8 +25,6 @@
|
||||||
| token | string | 是 | 签名验证代币 |
|
| token | string | 是 | 签名验证代币 |
|
||||||
| encoding_aes_key | string | 是 | 用于解密的 43 字符 AES 密钥 |
|
| encoding_aes_key | string | 是 | 用于解密的 43 字符 AES 密钥 |
|
||||||
| webhook_url | string | 是 | 用于发送回复的企业微信群聊机器人 Webhook URL |
|
| webhook_url | string | 是 | 用于发送回复的企业微信群聊机器人 Webhook URL |
|
||||||
| webhook_host | string | 否 | HTTP 服务器绑定地址(默认:0.0.0.0) |
|
|
||||||
| webhook_port | int | 否 | HTTP 服务器端口(默认:18793) |
|
|
||||||
| webhook_path | string | 否 | Webhook 端点路径(默认:/webhook/wecom) |
|
| webhook_path | string | 否 | Webhook 端点路径(默认:/webhook/wecom) |
|
||||||
| allow_from | array | 否 | 用户 ID 白名单(空值 = 允许所有用户) |
|
| allow_from | array | 否 | 用户 ID 白名单(空值 = 允许所有用户) |
|
||||||
| reply_timeout | int | 否 | 回复超时时间(单位:秒,默认值:5) |
|
| reply_timeout | int | 否 | 回复超时时间(单位:秒,默认值:5) |
|
||||||
|
|
@ -39,3 +35,5 @@
|
||||||
2. 获取 Webhook URL
|
2. 获取 Webhook URL
|
||||||
3. (如需接收消息) 在机器人配置页面设置接收消息的 API 地址(回调地址)以及 Token 和 EncodingAESKey
|
3. (如需接收消息) 在机器人配置页面设置接收消息的 API 地址(回调地址)以及 Token 和 EncodingAESKey
|
||||||
4. 将相关信息填入配置文件
|
4. 将相关信息填入配置文件
|
||||||
|
|
||||||
|
注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790。如需从公网接收回调,请把外部域名反向代理到 Gateway(默认端口 18790)。
|
||||||
|
|
|
||||||
33
docs/debug.md
Normal file
33
docs/debug.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Debugging PicoClaw
|
||||||
|
|
||||||
|
PicoClaw performs multiple complex interactions under the hood for every single request it receives—from routing messages and evaluating complexity, to executing tools and adapting to model failures. Being able to see exactly what is happening is crucial, not just for troubleshooting potential issues, but also for truly understanding how the agent operates.
|
||||||
|
## Starting PicoClaw in Debug Mode
|
||||||
|
|
||||||
|
To get detailed information about what the agent is doing (LLM requests, tool calls, message routing), you can start the PicoClaw gateway with the debug flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway --debug
|
||||||
|
# or
|
||||||
|
picoclaw gateway -d
|
||||||
|
```
|
||||||
|
|
||||||
|
In this mode, the system will format the logs extensively and display previews of system prompts and tool execution results.
|
||||||
|
|
||||||
|
## Disabling Log Truncation (Full Logs)
|
||||||
|
|
||||||
|
By default, PicoClaw truncates very long strings (such as the *System Prompt* or large JSON output results) in the debug logs to keep the console readable.
|
||||||
|
|
||||||
|
If you need to inspect the complete output of a command or the exact payload sent to the LLM model, you can use the `--no-truncate` flag.
|
||||||
|
|
||||||
|
**Note:** This flag *only* works when combined with the `--debug` mode.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway --debug --no-truncate
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
When this flag is active, the global truncation function is disabled. This is extremely useful for:
|
||||||
|
|
||||||
|
* Verifying the exact syntax of the messages sent to the provider.
|
||||||
|
* Reading the complete output of tools like `exec`, `web_fetch`, or `read_file`.
|
||||||
|
* Debugging the session history saved in memory.
|
||||||
61
docs/design/issue-783-investigation-and-fix-plan.zh.md
Normal file
61
docs/design/issue-783-investigation-and-fix-plan.zh.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Issue #783 调研与修复执行文档
|
||||||
|
|
||||||
|
## 1. 问题澄清(已确认)
|
||||||
|
|
||||||
|
- 现象:当 `agents.*.model.primary/fallbacks` 使用 `model_name` 别名(如 `step-3.5-flash`)时,fallback 链路将别名当作真实 `provider/model` 解析,导致 `provider` 可能为空、`model` 可能错误。
|
||||||
|
- 根因:`ResolveCandidates` 仅对字符串做 `ParseModelRef`,未先通过 `model_list` 将别名映射到真实 `model` 字段。
|
||||||
|
- 影响:
|
||||||
|
- fallback 执行可能把别名直接发给 OpenAI-compatible provider,触发 `Unknown Model`。
|
||||||
|
- `defaults.provider` 为空时,日志出现 `provider=` 空值。
|
||||||
|
|
||||||
|
## 2. 本次目标
|
||||||
|
|
||||||
|
- 修复 fallback 候选解析:优先通过 `model_list` 解析别名。
|
||||||
|
- 兼容旧行为:若未命中 `model_list`,继续走原有 `ParseModelRef` 兜底。
|
||||||
|
- 补充测试:覆盖别名、嵌套路径模型(如 `openrouter/stepfun/...`)、空默认 provider。
|
||||||
|
- 验证代码风格:与当前仓库风格保持一致(命名、错误处理、测试结构)。
|
||||||
|
|
||||||
|
## 3. 联网最佳实践调研结论(已完成)
|
||||||
|
|
||||||
|
- [x] 查阅 OpenAI-compatible 网关(如 OpenRouter)对 `model` 字段的推荐处理。
|
||||||
|
- [x] 查阅多 provider/fallback 设计最佳实践(候选解析、日志可观测性)。
|
||||||
|
- [x] 将外部建议映射为本仓库可执行约束。
|
||||||
|
|
||||||
|
外部参考要点(来自 OpenRouter/LiteLLM/Cloudflare AI Gateway 等官方文档):
|
||||||
|
|
||||||
|
- 优先显式配置,不依赖字符串切分推断 provider。
|
||||||
|
- 对网关模型标识应保留完整路径语义,避免截断导致 Unknown Model。
|
||||||
|
- fallback 与 primary 应复用同一解析策略,避免“主路径正确、降级路径错误”。
|
||||||
|
|
||||||
|
参考链接:
|
||||||
|
|
||||||
|
- OpenRouter Provider Routing: https://openrouter.ai/docs/guides/routing/provider-selection
|
||||||
|
- OpenRouter Model Fallbacks: https://openrouter.ai/docs/guides/routing/model-fallbacks
|
||||||
|
- OpenRouter Chat Completion API: https://openrouter.ai/docs/api-reference/chat-completion
|
||||||
|
- LiteLLM Router Architecture: https://docs.litellm.ai/docs/router_architecture
|
||||||
|
- Cloudflare AI Gateway Chat Completion: https://developers.cloudflare.com/ai-gateway/usage/chat-completion/
|
||||||
|
|
||||||
|
与本仓库对应的可执行约束:
|
||||||
|
|
||||||
|
- 在 fallback candidate 构建阶段先做 `model_name -> model_list.model` 映射。
|
||||||
|
- 未命中映射时保留旧解析行为,保证兼容性。
|
||||||
|
- 用新增测试锁定“别名 + 嵌套模型路径 + 空默认 provider”场景。
|
||||||
|
|
||||||
|
## 4. 实施步骤(顺序执行)
|
||||||
|
|
||||||
|
- [x] Step 1: 对齐现有代码模式,定位最小改动点(`pkg/agent` + `pkg/providers`)。
|
||||||
|
- [x] Step 2: 实现“基于 model_list 的 fallback 候选解析”。
|
||||||
|
- [x] Step 3: 增加/更新单元测试,覆盖 issue 场景。
|
||||||
|
- [x] Step 4: 代码风格一致性复核(与现有文件风格对照)。
|
||||||
|
- [x] Step 5: 运行质量门禁(LSP + `make check`)。
|
||||||
|
|
||||||
|
## 5. 执行记录
|
||||||
|
|
||||||
|
- 状态:已完成
|
||||||
|
- 已完成改动:
|
||||||
|
- `pkg/providers/fallback.go`:新增 `ResolveCandidatesWithLookup`,并保持 `ResolveCandidates` 向后兼容。
|
||||||
|
- `pkg/agent/instance.go`:在构建 fallback candidates 前,优先通过 `model_list` 解析别名,并对无协议模型补齐默认 `openai/` 前缀后再解析。
|
||||||
|
- `pkg/providers/fallback_test.go`:新增别名解析与去重测试。
|
||||||
|
- `pkg/agent/instance_test.go`:新增 agent 侧别名解析到嵌套模型路径、无协议模型解析测试。
|
||||||
|
- 风格对齐检查(完成):与 `pkg/providers/fallback_test.go`、`pkg/providers/model_ref_test.go` 现有模式一致。
|
||||||
|
- 质量验证(完成):先 `make generate`,后 `make check` 全量通过。
|
||||||
|
|
@ -66,7 +66,7 @@ Problem: Agent needs to know both `provider` and `model`, adding complexity.
|
||||||
Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
|
Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
|
||||||
|
|
||||||
1. **Model-centric**: Users care about models, not providers
|
1. **Model-centric**: Users care about models, not providers
|
||||||
2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.2`, `anthropic/claude-sonnet-4.6`
|
2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.4`, `anthropic/claude-sonnet-4.6`
|
||||||
3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes
|
3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes
|
||||||
|
|
||||||
### 2.2 New Configuration Structure
|
### 2.2 New Configuration Structure
|
||||||
|
|
@ -81,8 +81,8 @@ Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
|
||||||
"api_key": "sk-xxx"
|
"api_key": "sk-xxx"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-xxx"
|
"api_key": "sk-xxx"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -128,7 +128,7 @@ type Config struct {
|
||||||
type ModelConfig struct {
|
type ModelConfig struct {
|
||||||
// Required
|
// Required
|
||||||
ModelName string `json:"model_name"` // user-facing name (alias)
|
ModelName string `json:"model_name"` // user-facing name (alias)
|
||||||
Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.2
|
Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.4
|
||||||
|
|
||||||
// Common config
|
// Common config
|
||||||
APIBase string `json:"api_base,omitempty"`
|
APIBase string `json:"api_base,omitempty"`
|
||||||
|
|
@ -180,7 +180,7 @@ Identify protocol via prefix in `model` field:
|
||||||
"model": "deepseek-chat"
|
"model": "deepseek-chat"
|
||||||
},
|
},
|
||||||
"coder": {
|
"coder": {
|
||||||
"model": "gpt-5.2",
|
"model": "gpt-5.4",
|
||||||
"system_prompt": "You are a coding assistant..."
|
"system_prompt": "You are a coding assistant..."
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
|
|
@ -200,7 +200,7 @@ Each Agent only needs to specify `model` (corresponds to `model_name` in `model_
|
||||||
model_list:
|
model_list:
|
||||||
- model_name: gpt-4o
|
- model_name: gpt-4o
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: openai/gpt-5.2
|
model: openai/gpt-5.4
|
||||||
api_key: xxx
|
api_key: xxx
|
||||||
- model_name: my-custom
|
- model_name: my-custom
|
||||||
litellm_params:
|
litellm_params:
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ The new `model_list` configuration offers several advantages:
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +53,7 @@ The new `model_list` configuration offers several advantages:
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
},
|
},
|
||||||
|
|
@ -82,7 +82,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
||||||
|
|
||||||
| Prefix | Description | Example |
|
| Prefix | Description | Example |
|
||||||
|--------|-------------|---------|
|
|--------|-------------|---------|
|
||||||
| `openai/` | OpenAI API (default) | `openai/gpt-5.2` |
|
| `openai/` | OpenAI API (default) | `openai/gpt-5.4` |
|
||||||
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
|
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
|
||||||
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
|
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
|
||||||
| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |
|
| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |
|
||||||
|
|
@ -109,7 +109,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
||||||
| Field | Required | Description |
|
| Field | Required | Description |
|
||||||
|-------|----------|-------------|
|
|-------|----------|-------------|
|
||||||
| `model_name` | Yes | User-facing alias for the model |
|
| `model_name` | Yes | User-facing alias for the model |
|
||||||
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.2`) |
|
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.4`) |
|
||||||
| `api_base` | No | API endpoint URL |
|
| `api_base` | No | API endpoint URL |
|
||||||
| `api_key` | No* | API authentication key |
|
| `api_key` | No* | API authentication key |
|
||||||
| `proxy` | No | HTTP proxy URL |
|
| `proxy` | No | HTTP proxy URL |
|
||||||
|
|
@ -117,6 +117,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
||||||
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
|
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
|
||||||
| `rpm` | No | Requests per minute limit |
|
| `rpm` | No | Requests per minute limit |
|
||||||
| `max_tokens_field` | No | Field name for max tokens |
|
| `max_tokens_field` | No | Field name for max tokens |
|
||||||
|
| `request_timeout` | No | HTTP request timeout in seconds; `<=0` uses default `120s` |
|
||||||
|
|
||||||
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
|
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
|
||||||
|
|
||||||
|
|
@ -129,19 +130,19 @@ Configure multiple endpoints for the same model to distribute load:
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key1",
|
"api_key": "sk-key1",
|
||||||
"api_base": "https://api1.example.com/v1"
|
"api_base": "https://api1.example.com/v1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key2",
|
"api_key": "sk-key2",
|
||||||
"api_base": "https://api2.example.com/v1"
|
"api_base": "https://api2.example.com/v1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key3",
|
"api_key": "sk-key3",
|
||||||
"api_base": "https://api3.example.com/v1"
|
"api_base": "https://api3.example.com/v1"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,21 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": { ... },
|
"web": {
|
||||||
"exec": { ... },
|
...
|
||||||
"cron": { ... },
|
},
|
||||||
"skills": { ... }
|
"mcp": {
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"exec": {
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"cron": {
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
...
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -21,35 +32,35 @@ Web tools are used for web search and fetching.
|
||||||
|
|
||||||
### Brave
|
### Brave
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|---------------|--------|---------|---------------------------|
|
||||||
| `enabled` | bool | false | Enable Brave search |
|
| `enabled` | bool | false | Enable Brave search |
|
||||||
| `api_key` | string | - | Brave Search API key |
|
| `api_key` | string | - | Brave Search API key |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
||||||
### DuckDuckGo
|
### DuckDuckGo
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|---------------|------|---------|---------------------------|
|
||||||
| `enabled` | bool | true | Enable DuckDuckGo search |
|
| `enabled` | bool | true | Enable DuckDuckGo search |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
||||||
### Perplexity
|
### Perplexity
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|---------------|--------|---------|---------------------------|
|
||||||
| `enabled` | bool | false | Enable Perplexity search |
|
| `enabled` | bool | false | Enable Perplexity search |
|
||||||
| `api_key` | string | - | Perplexity API key |
|
| `api_key` | string | - | Perplexity API key |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
||||||
## Exec Tool
|
## Exec Tool
|
||||||
|
|
||||||
The exec tool is used to execute shell commands.
|
The exec tool is used to execute shell commands.
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|------------------------|-------|---------|--------------------------------------------|
|
||||||
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
||||||
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
||||||
|
|
||||||
### Functionality
|
### Functionality
|
||||||
|
|
||||||
|
|
@ -93,9 +104,167 @@ By default, PicoClaw blocks the following dangerous commands:
|
||||||
|
|
||||||
The cron tool is used for scheduling periodic tasks.
|
The cron tool is used for scheduling periodic tasks.
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|------------------------|------|---------|------------------------------------------------|
|
||||||
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
||||||
|
|
||||||
|
## MCP Tool
|
||||||
|
|
||||||
|
The MCP tool enables integration with external Model Context Protocol servers.
|
||||||
|
|
||||||
|
### Tool Discovery (Lazy Loading)
|
||||||
|
|
||||||
|
When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window
|
||||||
|
and increase API costs. The **Discovery** feature solves this by keeping MCP tools *hidden* by default.
|
||||||
|
|
||||||
|
Instead of loading all tools, the LLM is provided with a lightweight search tool (using BM25 keyword matching or Regex).
|
||||||
|
When the LLM needs a specific capability, it searches the hidden library. Matching tools are then temporarily "unlocked"
|
||||||
|
and injected into the context for a configured number of turns (`ttl`).
|
||||||
|
|
||||||
|
### Global Config
|
||||||
|
|
||||||
|
| Config | Type | Default | Description |
|
||||||
|
|-------------|--------|---------|----------------------------------------------|
|
||||||
|
| `enabled` | bool | false | Enable MCP integration globally |
|
||||||
|
| `discovery` | object | `{}` | Configuration for Tool Discovery (see below) |
|
||||||
|
| `servers` | object | `{}` | Map of server name to server config |
|
||||||
|
|
||||||
|
### Discovery Config (`discovery`)
|
||||||
|
|
||||||
|
| Config | Type | Default | Description |
|
||||||
|
|----------------------|------|---------|-----------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `enabled` | bool | false | If true, MCP tools are hidden and loaded on-demand via search. If false, all tools are loaded |
|
||||||
|
| `ttl` | int | 5 | Number of conversational turns a discovered tool remains unlocked |
|
||||||
|
| `max_search_results` | int | 5 | Maximum number of tools returned per search query |
|
||||||
|
| `use_bm25` | bool | true | Enable the natural language/keyword search tool (`tool_search_tool_bm25`). **Warning**: consumes more resources than regex search |
|
||||||
|
| `use_regex` | bool | false | Enable the regex pattern search tool (`tool_search_tool_regex`) |
|
||||||
|
|
||||||
|
> **Note:** If `discovery.enabled` is `true`, you MUST enable at least one search engine (`use_bm25` or `use_regex`),
|
||||||
|
> otherwise the application will fail to start.
|
||||||
|
|
||||||
|
### Per-Server Config
|
||||||
|
|
||||||
|
| Config | Type | Required | Description |
|
||||||
|
|------------|--------|----------|--------------------------------------------|
|
||||||
|
| `enabled` | bool | yes | Enable this MCP server |
|
||||||
|
| `type` | string | no | Transport type: `stdio`, `sse`, `http` |
|
||||||
|
| `command` | string | stdio | Executable command for stdio transport |
|
||||||
|
| `args` | array | no | Command arguments for stdio transport |
|
||||||
|
| `env` | object | no | Environment variables for stdio process |
|
||||||
|
| `env_file` | string | no | Path to environment file for stdio process |
|
||||||
|
| `url` | string | sse/http | Endpoint URL for `sse`/`http` transport |
|
||||||
|
| `headers` | object | no | HTTP headers for `sse`/`http` transport |
|
||||||
|
|
||||||
|
### Transport Behavior
|
||||||
|
|
||||||
|
- If `type` is omitted, transport is auto-detected:
|
||||||
|
- `url` is set → `sse`
|
||||||
|
- `command` is set → `stdio`
|
||||||
|
- `http` and `sse` both use `url` + optional `headers`.
|
||||||
|
- `env` and `env_file` are only applied to `stdio` servers.
|
||||||
|
|
||||||
|
### Configuration Examples
|
||||||
|
|
||||||
|
#### 1) Stdio MCP server
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"mcp": {
|
||||||
|
"enabled": true,
|
||||||
|
"servers": {
|
||||||
|
"filesystem": {
|
||||||
|
"enabled": true,
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-filesystem",
|
||||||
|
"/tmp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2) Remote SSE/HTTP MCP server
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"mcp": {
|
||||||
|
"enabled": true,
|
||||||
|
"servers": {
|
||||||
|
"remote-mcp": {
|
||||||
|
"enabled": true,
|
||||||
|
"type": "sse",
|
||||||
|
"url": "https://example.com/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer YOUR_TOKEN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3) Massive MCP setup with Tool Discovery enabled
|
||||||
|
|
||||||
|
*In this example, the LLM will only see the `tool_search_tool_bm25`. It will search and unlock Github or Postgres tools
|
||||||
|
dynamically only when requested by the user.*
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"mcp": {
|
||||||
|
"enabled": true,
|
||||||
|
"discovery": {
|
||||||
|
"enabled": true,
|
||||||
|
"ttl": 5,
|
||||||
|
"max_search_results": 5,
|
||||||
|
"use_bm25": true,
|
||||||
|
"use_regex": false
|
||||||
|
},
|
||||||
|
"servers": {
|
||||||
|
"github": {
|
||||||
|
"enabled": true,
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-github"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postgres": {
|
||||||
|
"enabled": true,
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-postgres",
|
||||||
|
"postgresql://user:password@localhost/dbname"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"slack": {
|
||||||
|
"enabled": true,
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-slack"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
|
||||||
|
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Skills Tool
|
## Skills Tool
|
||||||
|
|
||||||
|
|
@ -103,13 +272,14 @@ The skills tool configures skill discovery and installation via registries like
|
||||||
|
|
||||||
### Registries
|
### Registries
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|------------------------------------|--------|----------------------|----------------------------------------------|
|
||||||
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
||||||
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
|
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
|
||||||
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
|
| `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits |
|
||||||
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
|
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
|
||||||
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
|
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
|
||||||
|
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
|
||||||
|
|
||||||
### Configuration Example
|
### Configuration Example
|
||||||
|
|
||||||
|
|
@ -121,6 +291,7 @@ The skills tool configures skill discovery and installation via registries like
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"base_url": "https://clawhub.ai",
|
"base_url": "https://clawhub.ai",
|
||||||
|
"auth_token": "",
|
||||||
"search_path": "/api/v1/search",
|
"search_path": "/api/v1/search",
|
||||||
"skills_path": "/api/v1/skills",
|
"skills_path": "/api/v1/skills",
|
||||||
"download_path": "/api/v1/download"
|
"download_path": "/api/v1/download"
|
||||||
|
|
@ -136,8 +307,11 @@ The skills tool configures skill discovery and installation via registries like
|
||||||
All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_<SECTION>_<KEY>`:
|
All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_<SECTION>_<KEY>`:
|
||||||
|
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
||||||
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
||||||
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
||||||
|
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
||||||
|
|
||||||
Note: Array-type environment variables are not currently supported and must be set via the config file.
|
Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than
|
||||||
|
environment variables.
|
||||||
|
|
|
||||||
43
docs/troubleshooting.md
Normal file
43
docs/troubleshooting.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## "model ... not found in model_list" or OpenRouter "free is not a valid model ID"
|
||||||
|
|
||||||
|
**Symptom:** You see either:
|
||||||
|
|
||||||
|
- `Error creating provider: model "openrouter/free" not found in model_list`
|
||||||
|
- OpenRouter returns 400: `"free is not a valid model ID"`
|
||||||
|
|
||||||
|
**Cause:** The `model` field in your `model_list` entry is what gets sent to the API. For OpenRouter you must use the **full** model ID, not a shorthand.
|
||||||
|
|
||||||
|
- **Wrong:** `"model": "free"` → OpenRouter receives `free` and rejects it.
|
||||||
|
- **Right:** `"model": "openrouter/free"` → OpenRouter receives `openrouter/free` (auto free-tier routing).
|
||||||
|
|
||||||
|
**Fix:** In `~/.picoclaw/config.json` (or your config path):
|
||||||
|
|
||||||
|
1. **agents.defaults.model** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`).
|
||||||
|
2. That entry’s **model** must be a valid OpenRouter model ID, for example:
|
||||||
|
- `"openrouter/free"` – auto free-tier
|
||||||
|
- `"google/gemini-2.0-flash-exp:free"`
|
||||||
|
- `"meta-llama/llama-3.1-8b-instruct:free"`
|
||||||
|
|
||||||
|
Example snippet:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "openrouter-free"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "openrouter-free",
|
||||||
|
"model": "openrouter/free",
|
||||||
|
"api_key": "sk-or-v1-YOUR_OPENROUTER_KEY",
|
||||||
|
"api_base": "https://openrouter.ai/api/v1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Get your key at [OpenRouter Keys](https://openrouter.ai/keys).
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
# 企业微信自建应用 (WeCom App) 配置指南
|
|
||||||
|
|
||||||
本文档介绍如何在 PicoClaw 中配置企业微信自建应用 (wecom-app) 通道。
|
|
||||||
|
|
||||||
## 功能特性
|
|
||||||
|
|
||||||
| 功能 | 支持状态 |
|
|
||||||
|------|---------|
|
|
||||||
| 被动接收消息 | ✅ |
|
|
||||||
| 主动发送消息 | ✅ |
|
|
||||||
| 私聊 | ✅ |
|
|
||||||
| 群聊 | ❌ |
|
|
||||||
|
|
||||||
## 配置步骤
|
|
||||||
|
|
||||||
### 1. 企业微信后台配置
|
|
||||||
|
|
||||||
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
|
|
||||||
2. 进入"应用管理" → 选择自建应用
|
|
||||||
3. 记录以下信息:
|
|
||||||
- **AgentId**: 应用详情页显示
|
|
||||||
- **Secret**: 点击"查看"获取
|
|
||||||
4. 进入"我的企业"页面,记录 **企业ID** (CorpID)
|
|
||||||
|
|
||||||
### 2. 接收消息配置
|
|
||||||
|
|
||||||
1. 在应用详情页,点击"接收消息"的"设置API接收"
|
|
||||||
2. 填写以下信息:
|
|
||||||
- **URL**: `http://your-server:18792/webhook/wecom-app`
|
|
||||||
- **Token**: 随机生成或自定义(用于签名验证)
|
|
||||||
- **EncodingAESKey**: 点击"随机生成"生成43字符的密钥
|
|
||||||
3. 点击"保存"时,企业微信会发送验证请求
|
|
||||||
|
|
||||||
### 3. PicoClaw 配置
|
|
||||||
|
|
||||||
在 `config.json` 中添加以下配置:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"channels": {
|
|
||||||
"wecom_app": {
|
|
||||||
"enabled": true,
|
|
||||||
"corp_id": "wwxxxxxxxxxxxxxxxx", // 企业ID
|
|
||||||
"corp_secret": "xxxxxxxxxxxxxxxxxxxxxxxx", // 应用Secret
|
|
||||||
"agent_id": 1000002, // 应用AgentId
|
|
||||||
"token": "your_token", // 接收消息配置的Token
|
|
||||||
"encoding_aes_key": "your_encoding_aes_key", // 接收消息配置的EncodingAESKey
|
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
|
||||||
"allow_from": [],
|
|
||||||
"reply_timeout": 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### 1. 回调URL验证失败
|
|
||||||
|
|
||||||
**症状**: 企业微信保存API接收消息时提示验证失败
|
|
||||||
|
|
||||||
**检查项**:
|
|
||||||
- 确认服务器防火墙已开放 18792 端口
|
|
||||||
- 确认 `corp_id`、`token`、`encoding_aes_key` 配置正确
|
|
||||||
- 查看 PicoClaw 日志是否有请求到达
|
|
||||||
|
|
||||||
### 2. 中文消息解密失败
|
|
||||||
|
|
||||||
**症状**: 发送中文消息时出现 `invalid padding size` 错误
|
|
||||||
|
|
||||||
**原因**: 企业微信使用非标准的 PKCS7 填充(32字节块大小)
|
|
||||||
|
|
||||||
**解决**: 确保使用最新版本的 PicoClaw,已修复此问题。
|
|
||||||
|
|
||||||
### 3. 端口冲突
|
|
||||||
|
|
||||||
**症状**: 启动时提示端口已被占用
|
|
||||||
|
|
||||||
**解决**: 修改 `webhook_port` 为其他端口,如 18794
|
|
||||||
|
|
||||||
## 技术细节
|
|
||||||
|
|
||||||
### 加密算法
|
|
||||||
|
|
||||||
- **算法**: AES-256-CBC
|
|
||||||
- **密钥**: EncodingAESKey Base64解码后的32字节
|
|
||||||
- **IV**: AESKey的前16字节
|
|
||||||
- **填充**: PKCS7(块大小为32字节,非标准16字节)
|
|
||||||
- **消息格式**: XML
|
|
||||||
|
|
||||||
### 消息结构
|
|
||||||
|
|
||||||
解密后的消息格式:
|
|
||||||
```
|
|
||||||
random(16B) + msg_len(4B) + msg + receiveid
|
|
||||||
```
|
|
||||||
|
|
||||||
其中 `receiveid` 对于自建应用是 `corp_id`。
|
|
||||||
|
|
||||||
## 调试
|
|
||||||
|
|
||||||
启用调试模式查看详细日志:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
picoclaw gateway --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
关键日志标识:
|
|
||||||
- `wecom_app`: WeCom App 通道相关日志
|
|
||||||
- `wecom_common`: 加密解密相关日志
|
|
||||||
|
|
||||||
## 参考文档
|
|
||||||
|
|
||||||
- [企业微信官方文档 - 接收消息](https://developer.work.weixin.qq.com/document/path/96211)
|
|
||||||
- [企业微信官方加解密库](https://github.com/sbzhu/weworkapi_golang)
|
|
||||||
41
go.mod
41
go.mod
|
|
@ -8,25 +8,63 @@ require (
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
github.com/chzyer/readline v1.5.1
|
github.com/chzyer/readline v1.5.1
|
||||||
|
github.com/ergochat/irc-go v0.5.0
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/h2non/filetype v1.1.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.3.1
|
||||||
github.com/mymmrac/telego v1.6.0
|
github.com/mymmrac/telego v1.6.0
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||||
github.com/openai/openai-go/v3 v3.22.0
|
github.com/openai/openai-go/v3 v3.22.0
|
||||||
|
github.com/rivo/tview v0.42.0
|
||||||
|
github.com/rs/zerolog v1.34.0
|
||||||
github.com/slack-go/slack v0.17.3
|
github.com/slack-go/slack v0.17.3
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tencent-connect/botgo v0.2.1
|
github.com/tencent-connect/botgo v0.2.1
|
||||||
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
||||||
golang.org/x/oauth2 v0.35.0
|
golang.org/x/oauth2 v0.35.0
|
||||||
|
golang.org/x/time v0.14.0
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
|
maunium.net/go/mautrix v0.26.3
|
||||||
|
modernc.org/sqlite v1.46.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.1 // indirect
|
||||||
|
github.com/beeper/argo-go v1.1.2 // indirect
|
||||||
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
|
github.com/segmentio/encoding v0.5.3 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||||
|
go.mau.fi/libsignal v0.2.1 // indirect
|
||||||
|
go.mau.fi/util v0.9.6 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||||
|
golang.org/x/term v0.40.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.67.6 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
rsc.io/qr v0.2.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
@ -50,9 +88,10 @@ require (
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||||
github.com/valyala/fastjson v1.6.7 // indirect
|
github.com/valyala/fastjson v1.6.7 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
golang.org/x/arch v0.24.0 // indirect
|
golang.org/x/arch v0.24.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.50.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
134
go.sum
134
go.sum
|
|
@ -1,10 +1,20 @@
|
||||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
|
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
|
||||||
|
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
||||||
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
|
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
||||||
|
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
||||||
|
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
||||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
|
@ -25,14 +35,27 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
||||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||||
|
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||||
|
github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw=
|
||||||
|
github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
|
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||||
|
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||||
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
||||||
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
||||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||||
|
|
@ -42,8 +65,11 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m
|
||||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
|
@ -53,6 +79,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
|
||||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
|
@ -63,6 +91,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
|
@ -72,6 +102,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
||||||
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
||||||
|
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
|
||||||
|
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
|
@ -91,8 +125,25 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw=
|
||||||
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
||||||
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
|
@ -105,13 +156,31 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||||
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
||||||
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||||
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
|
||||||
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
|
||||||
|
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
|
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
|
||||||
|
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
|
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||||
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
||||||
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
|
@ -153,11 +222,21 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy
|
||||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
||||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
|
||||||
|
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
|
||||||
|
go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
|
||||||
|
go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
|
||||||
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
|
||||||
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
|
@ -171,10 +250,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
||||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
|
@ -188,8 +271,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
|
@ -217,8 +300,11 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
|
@ -227,6 +313,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||||
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
|
@ -234,8 +322,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
|
@ -243,6 +333,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|
@ -255,6 +347,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
|
@ -269,3 +363,35 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk=
|
||||||
|
maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38=
|
||||||
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||||
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||||
|
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||||
|
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||||
|
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||||
|
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,24 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextBuilder struct {
|
type ContextBuilder struct {
|
||||||
workspace string
|
workspace string
|
||||||
skillsLoader *skills.SkillsLoader
|
skillsLoader *skills.SkillsLoader
|
||||||
memory *MemoryStore
|
memory *MemoryStore
|
||||||
|
toolDiscoveryBM25 bool
|
||||||
|
toolDiscoveryRegex bool
|
||||||
|
|
||||||
// Cache for system prompt to avoid rebuilding on every call.
|
// Cache for system prompt to avoid rebuilding on every call.
|
||||||
// This fixes issue #607: repeated reprocessing of the entire context.
|
// This fixes issue #607: repeated reprocessing of the entire context.
|
||||||
|
|
@ -33,9 +38,23 @@ type ContextBuilder struct {
|
||||||
// created (didn't exist at cache time, now exist) or deleted (existed at
|
// created (didn't exist at cache time, now exist) or deleted (existed at
|
||||||
// cache time, now gone) — both of which should trigger a cache rebuild.
|
// cache time, now gone) — both of which should trigger a cache rebuild.
|
||||||
existedAtCache map[string]bool
|
existedAtCache map[string]bool
|
||||||
|
|
||||||
|
// skillFilesAtCache snapshots the skill tree file set and mtimes at cache
|
||||||
|
// build time. This catches nested file creations/deletions/mtime changes
|
||||||
|
// that may not update the top-level skill root directory mtime.
|
||||||
|
skillFilesAtCache map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder {
|
||||||
|
cb.toolDiscoveryBM25 = useBM25
|
||||||
|
cb.toolDiscoveryRegex = useRegex
|
||||||
|
return cb
|
||||||
}
|
}
|
||||||
|
|
||||||
func getGlobalConfigDir() string {
|
func getGlobalConfigDir() string {
|
||||||
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -46,8 +65,11 @@ func getGlobalConfigDir() string {
|
||||||
func NewContextBuilder(workspace string) *ContextBuilder {
|
func NewContextBuilder(workspace string) *ContextBuilder {
|
||||||
// builtin skills: skills directory in current project
|
// builtin skills: skills directory in current project
|
||||||
// Use the skills/ directory under the current working directory
|
// Use the skills/ directory under the current working directory
|
||||||
wd, _ := os.Getwd()
|
builtinSkillsDir := strings.TrimSpace(os.Getenv("PICOCLAW_BUILTIN_SKILLS"))
|
||||||
builtinSkillsDir := filepath.Join(wd, "skills")
|
if builtinSkillsDir == "" {
|
||||||
|
wd, _ := os.Getwd()
|
||||||
|
builtinSkillsDir = filepath.Join(wd, "skills")
|
||||||
|
}
|
||||||
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
|
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
|
||||||
|
|
||||||
return &ContextBuilder{
|
return &ContextBuilder{
|
||||||
|
|
@ -59,8 +81,11 @@ func NewContextBuilder(workspace string) *ContextBuilder {
|
||||||
|
|
||||||
func (cb *ContextBuilder) getIdentity() string {
|
func (cb *ContextBuilder) getIdentity() string {
|
||||||
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
||||||
|
toolDiscovery := cb.getDiscoveryRule()
|
||||||
|
version := config.FormatVersion()
|
||||||
|
|
||||||
return fmt.Sprintf(`# picoclaw 🦞
|
return fmt.Sprintf(
|
||||||
|
`# picoclaw 🦞 (%s)
|
||||||
|
|
||||||
You are picoclaw, a helpful AI assistant.
|
You are picoclaw, a helpful AI assistant.
|
||||||
|
|
||||||
|
|
@ -78,8 +103,29 @@ Your workspace is at: %s
|
||||||
|
|
||||||
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
|
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
|
||||||
|
|
||||||
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`,
|
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.
|
||||||
workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
|
|
||||||
|
%s`,
|
||||||
|
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb *ContextBuilder) getDiscoveryRule() string {
|
||||||
|
if !cb.toolDiscoveryBM25 && !cb.toolDiscoveryRegex {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolNames []string
|
||||||
|
if cb.toolDiscoveryBM25 {
|
||||||
|
toolNames = append(toolNames, `"tool_search_tool_bm25"`)
|
||||||
|
}
|
||||||
|
if cb.toolDiscoveryRegex {
|
||||||
|
toolNames = append(toolNames, `"tool_search_tool_regex"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`5. **Tool Discovery** - Your visible tools are limited to save memory, but a vast hidden library exists. If you lack the right tool for a task, BEFORE giving up, you MUST search using the %s tool. Do not refuse a request unless the search returns nothing. Found tools will temporarily unlock for your next turn.`,
|
||||||
|
strings.Join(toolNames, " or "),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) BuildSystemPrompt() string {
|
func (cb *ContextBuilder) BuildSystemPrompt() string {
|
||||||
|
|
@ -147,6 +193,7 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
|
||||||
cb.cachedSystemPrompt = prompt
|
cb.cachedSystemPrompt = prompt
|
||||||
cb.cachedAt = baseline.maxMtime
|
cb.cachedAt = baseline.maxMtime
|
||||||
cb.existedAtCache = baseline.existed
|
cb.existedAtCache = baseline.existed
|
||||||
|
cb.skillFilesAtCache = baseline.skillFiles
|
||||||
|
|
||||||
logger.DebugCF("agent", "System prompt cached",
|
logger.DebugCF("agent", "System prompt cached",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
|
|
@ -166,14 +213,14 @@ func (cb *ContextBuilder) InvalidateCache() {
|
||||||
cb.cachedSystemPrompt = ""
|
cb.cachedSystemPrompt = ""
|
||||||
cb.cachedAt = time.Time{}
|
cb.cachedAt = time.Time{}
|
||||||
cb.existedAtCache = nil
|
cb.existedAtCache = nil
|
||||||
|
cb.skillFilesAtCache = nil
|
||||||
|
|
||||||
logger.DebugCF("agent", "System prompt cache invalidated", nil)
|
logger.DebugCF("agent", "System prompt cache invalidated", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourcePaths returns the workspace source file paths tracked for cache
|
// sourcePaths returns non-skill workspace source files tracked for cache
|
||||||
// invalidation (bootstrap files + memory). The skills directory is handled
|
// invalidation (bootstrap files + memory). Skill roots are handled separately
|
||||||
// separately in sourceFilesChangedLocked because it requires both directory-
|
// because they require both directory-level and recursive file-level checks.
|
||||||
// level and recursive file-level mtime checks.
|
|
||||||
func (cb *ContextBuilder) sourcePaths() []string {
|
func (cb *ContextBuilder) sourcePaths() []string {
|
||||||
return []string{
|
return []string{
|
||||||
filepath.Join(cb.workspace, "AGENTS.md"),
|
filepath.Join(cb.workspace, "AGENTS.md"),
|
||||||
|
|
@ -184,23 +231,39 @@ func (cb *ContextBuilder) sourcePaths() []string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skillRoots returns all skill root directories that can affect
|
||||||
|
// BuildSkillsSummary output (workspace/global/builtin).
|
||||||
|
func (cb *ContextBuilder) skillRoots() []string {
|
||||||
|
if cb.skillsLoader == nil {
|
||||||
|
return []string{filepath.Join(cb.workspace, "skills")}
|
||||||
|
}
|
||||||
|
|
||||||
|
roots := cb.skillsLoader.SkillRoots()
|
||||||
|
if len(roots) == 0 {
|
||||||
|
return []string{filepath.Join(cb.workspace, "skills")}
|
||||||
|
}
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
// cacheBaseline holds the file existence snapshot and the latest observed
|
// cacheBaseline holds the file existence snapshot and the latest observed
|
||||||
// mtime across all tracked paths. Used as the cache reference point.
|
// mtime across all tracked paths. Used as the cache reference point.
|
||||||
type cacheBaseline struct {
|
type cacheBaseline struct {
|
||||||
existed map[string]bool
|
existed map[string]bool
|
||||||
maxMtime time.Time
|
skillFiles map[string]time.Time
|
||||||
|
maxMtime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildCacheBaseline records which tracked paths currently exist and computes
|
// buildCacheBaseline records which tracked paths currently exist and computes
|
||||||
// the latest mtime across all tracked files + skills directory contents.
|
// the latest mtime across all tracked files + skills directory contents.
|
||||||
// Called under write lock when the cache is built.
|
// Called under write lock when the cache is built.
|
||||||
func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
|
func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
|
||||||
skillsDir := filepath.Join(cb.workspace, "skills")
|
skillRoots := cb.skillRoots()
|
||||||
|
|
||||||
// All paths whose existence we track: source files + skills dir.
|
// All paths whose existence we track: source files + all skill roots.
|
||||||
allPaths := append(cb.sourcePaths(), skillsDir)
|
allPaths := append(cb.sourcePaths(), skillRoots...)
|
||||||
|
|
||||||
existed := make(map[string]bool, len(allPaths))
|
existed := make(map[string]bool, len(allPaths))
|
||||||
|
skillFiles := make(map[string]time.Time)
|
||||||
var maxMtime time.Time
|
var maxMtime time.Time
|
||||||
|
|
||||||
for _, p := range allPaths {
|
for _, p := range allPaths {
|
||||||
|
|
@ -211,17 +274,21 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walk skills files to capture their mtimes too.
|
// Walk all skill roots recursively to snapshot skill files and mtimes.
|
||||||
// Use os.Stat (not d.Info) to match the stat method used in
|
// Use os.Stat (not d.Info) for consistency with sourceFilesChanged checks.
|
||||||
// fileChangedSince / skillFilesModifiedSince for consistency.
|
for _, root := range skillRoots {
|
||||||
_ = filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error {
|
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
if walkErr == nil && !d.IsDir() {
|
if walkErr == nil && !d.IsDir() {
|
||||||
if info, err := os.Stat(path); err == nil && info.ModTime().After(maxMtime) {
|
if info, err := os.Stat(path); err == nil {
|
||||||
maxMtime = info.ModTime()
|
skillFiles[path] = info.ModTime()
|
||||||
|
if info.ModTime().After(maxMtime) {
|
||||||
|
maxMtime = info.ModTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return nil
|
||||||
return nil
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
// If no tracked files exist yet (empty workspace), maxMtime is zero.
|
// If no tracked files exist yet (empty workspace), maxMtime is zero.
|
||||||
// Use a very old non-zero time so that:
|
// Use a very old non-zero time so that:
|
||||||
|
|
@ -233,7 +300,7 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
|
||||||
maxMtime = time.Unix(1, 0)
|
maxMtime = time.Unix(1, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cacheBaseline{existed: existed, maxMtime: maxMtime}
|
return cacheBaseline{existed: existed, skillFiles: skillFiles, maxMtime: maxMtime}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceFilesChangedLocked checks whether any workspace source file has been
|
// sourceFilesChangedLocked checks whether any workspace source file has been
|
||||||
|
|
@ -249,27 +316,21 @@ func (cb *ContextBuilder) sourceFilesChangedLocked() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check tracked source files (bootstrap + memory).
|
// Check tracked source files (bootstrap + memory).
|
||||||
for _, p := range cb.sourcePaths() {
|
if slices.ContainsFunc(cb.sourcePaths(), cb.fileChangedSince) {
|
||||||
if cb.fileChangedSince(p) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Skills directory (handled separately from sourcePaths) ---
|
|
||||||
//
|
|
||||||
// 1. Creation/deletion: tracked via existedAtCache, same as bootstrap files.
|
|
||||||
skillsDir := filepath.Join(cb.workspace, "skills")
|
|
||||||
if cb.fileChangedSince(skillsDir) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Structural changes (add/remove entries inside the dir) are reflected
|
// --- Skill roots (workspace/global/builtin) ---
|
||||||
// in the directory's own mtime, which fileChangedSince already checks.
|
|
||||||
//
|
//
|
||||||
// 3. Content-only edits to files inside skills/ do NOT update the parent
|
// For each root:
|
||||||
// directory mtime on most filesystems, so we recursively walk to check
|
// 1. Creation/deletion and root directory mtime changes are tracked by fileChangedSince.
|
||||||
// individual file mtimes at any nesting depth.
|
// 2. Nested file create/delete/mtime changes are tracked by the skill file snapshot.
|
||||||
if skillFilesModifiedSince(skillsDir, cb.cachedAt) {
|
for _, root := range cb.skillRoots() {
|
||||||
|
if cb.fileChangedSince(root) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if skillFilesChangedSince(cb.skillRoots(), cb.skillFilesAtCache) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -310,28 +371,64 @@ func (cb *ContextBuilder) fileChangedSince(path string) bool {
|
||||||
// if the callback returned nil when its err parameter is non-nil.
|
// if the callback returned nil when its err parameter is non-nil.
|
||||||
var errWalkStop = errors.New("walk stop")
|
var errWalkStop = errors.New("walk stop")
|
||||||
|
|
||||||
// skillFilesModifiedSince recursively walks the skills directory and checks
|
// skillFilesChangedSince compares the current recursive skill file tree
|
||||||
// whether any file was modified after t. This catches content-only edits at
|
// against the cache-time snapshot. Any create/delete/mtime drift invalidates
|
||||||
// any nesting depth (e.g. skills/name/docs/extra.md) that don't update
|
// the cache.
|
||||||
// parent directory mtimes.
|
func skillFilesChangedSince(skillRoots []string, filesAtCache map[string]time.Time) bool {
|
||||||
func skillFilesModifiedSince(skillsDir string, t time.Time) bool {
|
// Defensive: if the snapshot was never initialized, force rebuild.
|
||||||
changed := false
|
if filesAtCache == nil {
|
||||||
err := filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error {
|
return true
|
||||||
if walkErr == nil && !d.IsDir() {
|
|
||||||
if info, statErr := os.Stat(path); statErr == nil && info.ModTime().After(t) {
|
|
||||||
changed = true
|
|
||||||
return errWalkStop // stop walking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
// errWalkStop is expected (early exit on first changed file).
|
|
||||||
// os.IsNotExist means the skills dir doesn't exist yet — not an error.
|
|
||||||
// Any other error is unexpected and worth logging.
|
|
||||||
if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) {
|
|
||||||
logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()})
|
|
||||||
}
|
}
|
||||||
return changed
|
|
||||||
|
// Check cached files still exist and keep the same mtime.
|
||||||
|
for path, cachedMtime := range filesAtCache {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
// A previously tracked file disappeared (or became inaccessible):
|
||||||
|
// either way, cached skill summary may now be stale.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !info.ModTime().Equal(cachedMtime) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check no new files appeared under any skill root.
|
||||||
|
changed := false
|
||||||
|
for _, root := range skillRoots {
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
// Treat unexpected walk errors as changed to avoid stale cache.
|
||||||
|
if !os.IsNotExist(walkErr) {
|
||||||
|
changed = true
|
||||||
|
return errWalkStop
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := filesAtCache[path]; !ok {
|
||||||
|
changed = true
|
||||||
|
return errWalkStop
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) {
|
||||||
|
logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||||
|
|
@ -442,10 +539,7 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
})
|
})
|
||||||
|
|
||||||
// Log preview of system prompt (avoid logging huge content)
|
// Log preview of system prompt (avoid logging huge content)
|
||||||
preview := fullSystemPrompt
|
preview := utils.Truncate(fullSystemPrompt, 500)
|
||||||
if len(preview) > 500 {
|
|
||||||
preview = preview[:500] + "... (truncated)"
|
|
||||||
}
|
|
||||||
logger.DebugCF("agent", "System prompt preview",
|
logger.DebugCF("agent", "System prompt preview",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"preview": preview,
|
"preview": preview,
|
||||||
|
|
@ -467,10 +561,14 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
|
|
||||||
// Add current user message
|
// Add current user message
|
||||||
if strings.TrimSpace(currentMessage) != "" {
|
if strings.TrimSpace(currentMessage) != "" {
|
||||||
messages = append(messages, providers.Message{
|
msg := providers.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: currentMessage,
|
Content: currentMessage,
|
||||||
})
|
}
|
||||||
|
if len(media) > 0 {
|
||||||
|
msg.Media = media
|
||||||
|
}
|
||||||
|
messages = append(messages, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
|
|
@ -538,7 +636,60 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitized
|
// Second pass: ensure every assistant message with tool_calls has matching
|
||||||
|
// tool result messages following it. This is required by strict providers
|
||||||
|
// like DeepSeek that enforce: "An assistant message with 'tool_calls' must
|
||||||
|
// be followed by tool messages responding to each 'tool_call_id'."
|
||||||
|
final := make([]providers.Message, 0, len(sanitized))
|
||||||
|
for i := 0; i < len(sanitized); i++ {
|
||||||
|
msg := sanitized[i]
|
||||||
|
if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
|
||||||
|
// Collect expected tool_call IDs
|
||||||
|
expected := make(map[string]bool, len(msg.ToolCalls))
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
expected[tc.ID] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check following messages for matching tool results
|
||||||
|
toolMsgCount := 0
|
||||||
|
for j := i + 1; j < len(sanitized); j++ {
|
||||||
|
if sanitized[j].Role != "tool" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
toolMsgCount++
|
||||||
|
if _, exists := expected[sanitized[j].ToolCallID]; exists {
|
||||||
|
expected[sanitized[j].ToolCallID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If any tool_call_id is missing, drop this assistant message and its partial tool messages
|
||||||
|
allFound := true
|
||||||
|
for toolCallID, found := range expected {
|
||||||
|
if !found {
|
||||||
|
allFound = false
|
||||||
|
logger.DebugCF(
|
||||||
|
"agent",
|
||||||
|
"Dropping assistant message with incomplete tool results",
|
||||||
|
map[string]any{
|
||||||
|
"missing_tool_call_id": toolCallID,
|
||||||
|
"expected_count": len(expected),
|
||||||
|
"found_count": toolMsgCount,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allFound {
|
||||||
|
// Skip this assistant message and its tool messages
|
||||||
|
i += toolMsgCount
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final = append(final, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return final
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) AddToolResult(
|
func (cb *ContextBuilder) AddToolResult(
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,162 @@ Updated content.`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGlobalSkillFileContentChange verifies that modifying a global skill
|
||||||
|
// (~/.picoclaw/skills) invalidates the cached system prompt.
|
||||||
|
func TestGlobalSkillFileContentChange(t *testing.T) {
|
||||||
|
tmpHome := t.TempDir()
|
||||||
|
t.Setenv("HOME", tmpHome)
|
||||||
|
|
||||||
|
tmpDir := setupWorkspace(t, nil)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
globalSkillPath := filepath.Join(tmpHome, ".picoclaw", "skills", "global-skill", "SKILL.md")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(globalSkillPath), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v1 := `---
|
||||||
|
name: global-skill
|
||||||
|
description: global-v1
|
||||||
|
---
|
||||||
|
# Global Skill v1`
|
||||||
|
if err := os.WriteFile(globalSkillPath, []byte(v1), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
sp1 := cb.BuildSystemPromptWithCache()
|
||||||
|
if !strings.Contains(sp1, "global-v1") {
|
||||||
|
t.Fatal("expected initial prompt to contain global skill description")
|
||||||
|
}
|
||||||
|
|
||||||
|
v2 := `---
|
||||||
|
name: global-skill
|
||||||
|
description: global-v2
|
||||||
|
---
|
||||||
|
# Global Skill v2`
|
||||||
|
if err := os.WriteFile(globalSkillPath, []byte(v2), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
future := time.Now().Add(2 * time.Second)
|
||||||
|
if err := os.Chtimes(globalSkillPath, future, future); err != nil {
|
||||||
|
t.Fatalf("failed to update mtime for %s: %v", globalSkillPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.systemPromptMutex.RLock()
|
||||||
|
changed := cb.sourceFilesChangedLocked()
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("sourceFilesChangedLocked() should detect global skill file content change")
|
||||||
|
}
|
||||||
|
|
||||||
|
sp2 := cb.BuildSystemPromptWithCache()
|
||||||
|
if !strings.Contains(sp2, "global-v2") {
|
||||||
|
t.Error("rebuilt prompt should contain updated global skill description")
|
||||||
|
}
|
||||||
|
if sp1 == sp2 {
|
||||||
|
t.Error("cache should be invalidated when global skill file content changes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuiltinSkillFileContentChange verifies that modifying a builtin skill
|
||||||
|
// invalidates the cached system prompt.
|
||||||
|
func TestBuiltinSkillFileContentChange(t *testing.T) {
|
||||||
|
tmpHome := t.TempDir()
|
||||||
|
t.Setenv("HOME", tmpHome)
|
||||||
|
|
||||||
|
tmpDir := setupWorkspace(t, nil)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
builtinRoot := t.TempDir()
|
||||||
|
t.Setenv("PICOCLAW_BUILTIN_SKILLS", builtinRoot)
|
||||||
|
|
||||||
|
builtinSkillPath := filepath.Join(builtinRoot, "builtin-skill", "SKILL.md")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(builtinSkillPath), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v1 := `---
|
||||||
|
name: builtin-skill
|
||||||
|
description: builtin-v1
|
||||||
|
---
|
||||||
|
# Builtin Skill v1`
|
||||||
|
if err := os.WriteFile(builtinSkillPath, []byte(v1), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
sp1 := cb.BuildSystemPromptWithCache()
|
||||||
|
if !strings.Contains(sp1, "builtin-v1") {
|
||||||
|
t.Fatal("expected initial prompt to contain builtin skill description")
|
||||||
|
}
|
||||||
|
|
||||||
|
v2 := `---
|
||||||
|
name: builtin-skill
|
||||||
|
description: builtin-v2
|
||||||
|
---
|
||||||
|
# Builtin Skill v2`
|
||||||
|
if err := os.WriteFile(builtinSkillPath, []byte(v2), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
future := time.Now().Add(2 * time.Second)
|
||||||
|
if err := os.Chtimes(builtinSkillPath, future, future); err != nil {
|
||||||
|
t.Fatalf("failed to update mtime for %s: %v", builtinSkillPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.systemPromptMutex.RLock()
|
||||||
|
changed := cb.sourceFilesChangedLocked()
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("sourceFilesChangedLocked() should detect builtin skill file content change")
|
||||||
|
}
|
||||||
|
|
||||||
|
sp2 := cb.BuildSystemPromptWithCache()
|
||||||
|
if !strings.Contains(sp2, "builtin-v2") {
|
||||||
|
t.Error("rebuilt prompt should contain updated builtin skill description")
|
||||||
|
}
|
||||||
|
if sp1 == sp2 {
|
||||||
|
t.Error("cache should be invalidated when builtin skill file content changes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSkillFileDeletionInvalidatesCache verifies that deleting a nested skill
|
||||||
|
// file invalidates the cached system prompt.
|
||||||
|
func TestSkillFileDeletionInvalidatesCache(t *testing.T) {
|
||||||
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
|
"skills/delete-me/SKILL.md": `---
|
||||||
|
name: delete-me
|
||||||
|
description: delete-me-v1
|
||||||
|
---
|
||||||
|
# Delete Me`,
|
||||||
|
})
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
sp1 := cb.BuildSystemPromptWithCache()
|
||||||
|
if !strings.Contains(sp1, "delete-me-v1") {
|
||||||
|
t.Fatal("expected initial prompt to contain skill description")
|
||||||
|
}
|
||||||
|
|
||||||
|
skillPath := filepath.Join(tmpDir, "skills", "delete-me", "SKILL.md")
|
||||||
|
if err := os.Remove(skillPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.systemPromptMutex.RLock()
|
||||||
|
changed := cb.sourceFilesChangedLocked()
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("sourceFilesChangedLocked() should detect deleted skill file")
|
||||||
|
}
|
||||||
|
|
||||||
|
sp2 := cb.BuildSystemPromptWithCache()
|
||||||
|
if strings.Contains(sp2, "delete-me-v1") {
|
||||||
|
t.Error("rebuilt prompt should not contain deleted skill description")
|
||||||
|
}
|
||||||
|
if sp1 == sp2 {
|
||||||
|
t.Error("cache should be invalidated when skill file is deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines
|
// TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines
|
||||||
// can safely call BuildSystemPromptWithCache concurrently without producing
|
// can safely call BuildSystemPromptWithCache concurrently without producing
|
||||||
// empty results, panics, or data races.
|
// empty results, panics, or data races.
|
||||||
|
|
@ -404,11 +560,11 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
errs := make(chan string, goroutines*iterations)
|
errs := make(chan string, goroutines*iterations)
|
||||||
|
|
||||||
for g := 0; g < goroutines; g++ {
|
for g := range goroutines {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(id int) {
|
go func(id int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i := 0; i < iterations; i++ {
|
for i := range iterations {
|
||||||
result := cb.BuildSystemPromptWithCache()
|
result := cb.BuildSystemPromptWithCache()
|
||||||
if result == "" {
|
if result == "" {
|
||||||
errs <- "empty prompt returned"
|
errs <- "empty prompt returned"
|
||||||
|
|
|
||||||
|
|
@ -207,3 +207,77 @@ func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSanitizeHistoryForProvider_IncompleteToolResults tests the forward validation
|
||||||
|
// that ensures assistant messages with tool_calls have ALL matching tool results.
|
||||||
|
// This fixes the DeepSeek error: "An assistant message with 'tool_calls' must be
|
||||||
|
// followed by tool messages responding to each 'tool_call_id'."
|
||||||
|
func TestSanitizeHistoryForProvider_IncompleteToolResults(t *testing.T) {
|
||||||
|
// Assistant expects tool results for both A and B, but only A is present
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "do two things"),
|
||||||
|
assistantWithTools("A", "B"),
|
||||||
|
toolResult("A"),
|
||||||
|
// toolResult("B") is missing - this would cause DeepSeek to fail
|
||||||
|
msg("user", "next question"),
|
||||||
|
msg("assistant", "answer"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
// The assistant message with incomplete tool results should be dropped,
|
||||||
|
// along with its partial tool result. The remaining messages are:
|
||||||
|
// user ("do two things"), user ("next question"), assistant ("answer")
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "user", "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSanitizeHistoryForProvider_MissingAllToolResults tests the case where
|
||||||
|
// an assistant message has tool_calls but no tool results follow at all.
|
||||||
|
func TestSanitizeHistoryForProvider_MissingAllToolResults(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "do something"),
|
||||||
|
assistantWithTools("A"),
|
||||||
|
// No tool results at all
|
||||||
|
msg("user", "hello"),
|
||||||
|
msg("assistant", "hi"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
// The assistant message with no tool results should be dropped.
|
||||||
|
// Remaining: user ("do something"), user ("hello"), assistant ("hi")
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "user", "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSanitizeHistoryForProvider_PartialToolResultsInMiddle tests that
|
||||||
|
// incomplete tool results in the middle of a conversation are properly handled.
|
||||||
|
func TestSanitizeHistoryForProvider_PartialToolResultsInMiddle(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "first"),
|
||||||
|
assistantWithTools("A"),
|
||||||
|
toolResult("A"),
|
||||||
|
msg("assistant", "done"),
|
||||||
|
msg("user", "second"),
|
||||||
|
assistantWithTools("B", "C"),
|
||||||
|
toolResult("B"),
|
||||||
|
// toolResult("C") is missing
|
||||||
|
msg("user", "third"),
|
||||||
|
assistantWithTools("D"),
|
||||||
|
toolResult("D"),
|
||||||
|
msg("assistant", "all done"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
// First round is complete (user, assistant+tools, tool, assistant),
|
||||||
|
// second round is incomplete and dropped (assistant+tools, partial tool),
|
||||||
|
// third round is complete (user, assistant+tools, tool, assistant).
|
||||||
|
// Remaining: user, assistant, tool, assistant, user, user, assistant, tool, assistant
|
||||||
|
if len(result) != 9 {
|
||||||
|
t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "assistant", "tool", "assistant", "user", "user", "assistant", "tool", "assistant")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/memory"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
"github.com/sipeed/picoclaw/pkg/session"
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
|
@ -15,22 +20,33 @@ import (
|
||||||
// AgentInstance represents a fully configured agent with its own workspace,
|
// AgentInstance represents a fully configured agent with its own workspace,
|
||||||
// session manager, context builder, and tool registry.
|
// session manager, context builder, and tool registry.
|
||||||
type AgentInstance struct {
|
type AgentInstance struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Model string
|
Model string
|
||||||
Fallbacks []string
|
Fallbacks []string
|
||||||
Workspace string
|
Workspace string
|
||||||
MaxIterations int
|
MaxIterations int
|
||||||
MaxTokens int
|
MaxTokens int
|
||||||
Temperature float64
|
Temperature float64
|
||||||
ContextWindow int
|
ThinkingLevel ThinkingLevel
|
||||||
Provider providers.LLMProvider
|
ContextWindow int
|
||||||
Sessions *session.SessionManager
|
SummarizeMessageThreshold int
|
||||||
ContextBuilder *ContextBuilder
|
SummarizeTokenPercent int
|
||||||
Tools *tools.ToolRegistry
|
Provider providers.LLMProvider
|
||||||
Subagents *config.SubagentsConfig
|
Sessions session.SessionStore
|
||||||
SkillsFilter []string
|
ContextBuilder *ContextBuilder
|
||||||
Candidates []providers.FallbackCandidate
|
Tools *tools.ToolRegistry
|
||||||
|
Subagents *config.SubagentsConfig
|
||||||
|
SkillsFilter []string
|
||||||
|
Candidates []providers.FallbackCandidate
|
||||||
|
|
||||||
|
// Router is non-nil when model routing is configured and the light model
|
||||||
|
// was successfully resolved. It scores each incoming message and decides
|
||||||
|
// whether to route to LightCandidates or stay with Candidates.
|
||||||
|
Router *routing.Router
|
||||||
|
// LightCandidates holds the resolved provider candidates for the light model.
|
||||||
|
// Pre-computed at agent creation to avoid repeated model_list lookups at runtime.
|
||||||
|
LightCandidates []providers.FallbackCandidate
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentInstance creates an agent instance from config.
|
// NewAgentInstance creates an agent instance from config.
|
||||||
|
|
@ -47,18 +63,47 @@ func NewAgentInstance(
|
||||||
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
|
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
|
||||||
|
|
||||||
restrict := defaults.RestrictToWorkspace
|
restrict := defaults.RestrictToWorkspace
|
||||||
|
readRestrict := restrict && !defaults.AllowReadOutsideWorkspace
|
||||||
|
|
||||||
|
// Compile path whitelist patterns from config.
|
||||||
|
allowReadPaths := compilePatterns(cfg.Tools.AllowReadPaths)
|
||||||
|
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
|
||||||
|
|
||||||
toolsRegistry := tools.NewToolRegistry()
|
toolsRegistry := tools.NewToolRegistry()
|
||||||
toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
|
|
||||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
|
if cfg.Tools.IsToolEnabled("read_file") {
|
||||||
toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
|
maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
|
||||||
toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg))
|
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
|
||||||
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
|
}
|
||||||
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
|
if cfg.Tools.IsToolEnabled("write_file") {
|
||||||
|
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("list_dir") {
|
||||||
|
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("exec") {
|
||||||
|
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
|
||||||
|
}
|
||||||
|
toolsRegistry.Register(execTool)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Tools.IsToolEnabled("edit_file") {
|
||||||
|
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("append_file") {
|
||||||
|
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
|
||||||
sessionsDir := filepath.Join(workspace, "sessions")
|
sessionsDir := filepath.Join(workspace, "sessions")
|
||||||
sessionsManager := session.NewSessionManager(sessionsDir)
|
sessions := initSessionStore(sessionsDir)
|
||||||
|
|
||||||
contextBuilder := NewContextBuilder(workspace)
|
mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
|
||||||
|
contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(
|
||||||
|
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
|
||||||
|
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,
|
||||||
|
)
|
||||||
|
|
||||||
agentID := routing.DefaultAgentID
|
agentID := routing.DefaultAgentID
|
||||||
agentName := ""
|
agentName := ""
|
||||||
|
|
@ -87,30 +132,110 @@ func NewAgentInstance(
|
||||||
temperature = *defaults.Temperature
|
temperature = *defaults.Temperature
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var thinkingLevelStr string
|
||||||
|
if mc, err := cfg.GetModelConfig(model); err == nil {
|
||||||
|
thinkingLevelStr = mc.ThinkingLevel
|
||||||
|
}
|
||||||
|
thinkingLevel := parseThinkingLevel(thinkingLevelStr)
|
||||||
|
|
||||||
|
summarizeMessageThreshold := defaults.SummarizeMessageThreshold
|
||||||
|
if summarizeMessageThreshold == 0 {
|
||||||
|
summarizeMessageThreshold = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
summarizeTokenPercent := defaults.SummarizeTokenPercent
|
||||||
|
if summarizeTokenPercent == 0 {
|
||||||
|
summarizeTokenPercent = 75
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve fallback candidates
|
// Resolve fallback candidates
|
||||||
modelCfg := providers.ModelConfig{
|
modelCfg := providers.ModelConfig{
|
||||||
Primary: model,
|
Primary: model,
|
||||||
Fallbacks: fallbacks,
|
Fallbacks: fallbacks,
|
||||||
}
|
}
|
||||||
candidates := providers.ResolveCandidates(modelCfg, defaults.Provider)
|
resolveFromModelList := func(raw string) (string, bool) {
|
||||||
|
ensureProtocol := func(model string) string {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if model == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(model, "/") {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return "openai/" + model
|
||||||
|
}
|
||||||
|
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg != nil {
|
||||||
|
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
|
||||||
|
return ensureProtocol(mc.Model), true
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
|
||||||
|
if fullModel == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fullModel == raw {
|
||||||
|
return ensureProtocol(fullModel), true
|
||||||
|
}
|
||||||
|
_, modelID := providers.ExtractProtocol(fullModel)
|
||||||
|
if modelID == raw {
|
||||||
|
return ensureProtocol(fullModel), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
|
||||||
|
|
||||||
|
// Model routing setup: pre-resolve light model candidates at creation time
|
||||||
|
// to avoid repeated model_list lookups on every incoming message.
|
||||||
|
var router *routing.Router
|
||||||
|
var lightCandidates []providers.FallbackCandidate
|
||||||
|
if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" {
|
||||||
|
lightModelCfg := providers.ModelConfig{Primary: rc.LightModel}
|
||||||
|
resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList)
|
||||||
|
if len(resolved) > 0 {
|
||||||
|
router = routing.New(routing.RouterConfig{
|
||||||
|
LightModel: rc.LightModel,
|
||||||
|
Threshold: rc.Threshold,
|
||||||
|
})
|
||||||
|
lightCandidates = resolved
|
||||||
|
} else {
|
||||||
|
log.Printf("routing: light_model %q not found in model_list — routing disabled for agent %q",
|
||||||
|
rc.LightModel, agentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &AgentInstance{
|
return &AgentInstance{
|
||||||
ID: agentID,
|
ID: agentID,
|
||||||
Name: agentName,
|
Name: agentName,
|
||||||
Model: model,
|
Model: model,
|
||||||
Fallbacks: fallbacks,
|
Fallbacks: fallbacks,
|
||||||
Workspace: workspace,
|
Workspace: workspace,
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
MaxTokens: maxTokens,
|
MaxTokens: maxTokens,
|
||||||
Temperature: temperature,
|
Temperature: temperature,
|
||||||
ContextWindow: maxTokens,
|
ThinkingLevel: thinkingLevel,
|
||||||
Provider: provider,
|
ContextWindow: maxTokens,
|
||||||
Sessions: sessionsManager,
|
SummarizeMessageThreshold: summarizeMessageThreshold,
|
||||||
ContextBuilder: contextBuilder,
|
SummarizeTokenPercent: summarizeTokenPercent,
|
||||||
Tools: toolsRegistry,
|
Provider: provider,
|
||||||
Subagents: subagents,
|
Sessions: sessions,
|
||||||
SkillsFilter: skillsFilter,
|
ContextBuilder: contextBuilder,
|
||||||
Candidates: candidates,
|
Tools: toolsRegistry,
|
||||||
|
Subagents: subagents,
|
||||||
|
SkillsFilter: skillsFilter,
|
||||||
|
Candidates: candidates,
|
||||||
|
Router: router,
|
||||||
|
LightCandidates: lightCandidates,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,12 +244,13 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD
|
||||||
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
||||||
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
||||||
}
|
}
|
||||||
|
// Use the configured default workspace (respects PICOCLAW_HOME)
|
||||||
if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
|
if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
|
||||||
return expandHome(defaults.Workspace)
|
return expandHome(defaults.Workspace)
|
||||||
}
|
}
|
||||||
home, _ := os.UserHomeDir()
|
// For named agents without explicit workspace, use default workspace with agent ID suffix
|
||||||
id := routing.NormalizeAgentID(agentCfg.ID)
|
id := routing.NormalizeAgentID(agentCfg.ID)
|
||||||
return filepath.Join(home, ".picoclaw", "workspace-"+id)
|
return filepath.Join(expandHome(defaults.Workspace), "..", "workspace-"+id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAgentModel resolves the primary model for an agent.
|
// resolveAgentModel resolves the primary model for an agent.
|
||||||
|
|
@ -143,6 +269,52 @@ func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentD
|
||||||
return defaults.ModelFallbacks
|
return defaults.ModelFallbacks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func compilePatterns(patterns []string) []*regexp.Regexp {
|
||||||
|
compiled := make([]*regexp.Regexp, 0, len(patterns))
|
||||||
|
for _, p := range patterns {
|
||||||
|
re, err := regexp.Compile(p)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: invalid path pattern %q: %v\n", p, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
compiled = append(compiled, re)
|
||||||
|
}
|
||||||
|
return compiled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases resources held by the agent's session store.
|
||||||
|
func (a *AgentInstance) Close() error {
|
||||||
|
if a.Sessions != nil {
|
||||||
|
return a.Sessions.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// initSessionStore creates the session persistence backend.
|
||||||
|
// It uses the JSONL store by default and auto-migrates legacy JSON sessions.
|
||||||
|
// Falls back to SessionManager if the JSONL store cannot be initialized or
|
||||||
|
// if migration fails (which indicates the store cannot write reliably).
|
||||||
|
func initSessionStore(dir string) session.SessionStore {
|
||||||
|
store, err := memory.NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("memory: init store: %v; using json sessions", err)
|
||||||
|
return session.NewSessionManager(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n, merr := memory.MigrateFromJSON(context.Background(), dir, store); merr != nil {
|
||||||
|
// Migration failure means the store could not write data.
|
||||||
|
// Fall back to SessionManager to avoid a split state where
|
||||||
|
// some sessions are in JSONL and others remain in JSON.
|
||||||
|
log.Printf("memory: migration failed: %v; falling back to json sessions", merr)
|
||||||
|
store.Close()
|
||||||
|
return session.NewSessionManager(dir)
|
||||||
|
} else if n > 0 {
|
||||||
|
log.Printf("memory: migrated %d session(s) to jsonl", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.NewJSONLBackend(store)
|
||||||
|
}
|
||||||
|
|
||||||
func expandHome(path string) string {
|
func expandHome(path string) string {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return path
|
return path
|
||||||
|
|
|
||||||
|
|
@ -93,3 +93,70 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
|
||||||
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
|
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
aliasName string
|
||||||
|
modelName string
|
||||||
|
apiBase string
|
||||||
|
wantProvider string
|
||||||
|
wantModel string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "alias with provider prefix",
|
||||||
|
aliasName: "step-3.5-flash",
|
||||||
|
modelName: "openrouter/stepfun/step-3.5-flash:free",
|
||||||
|
apiBase: "https://openrouter.ai/api/v1",
|
||||||
|
wantProvider: "openrouter",
|
||||||
|
wantModel: "stepfun/step-3.5-flash:free",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "alias without provider prefix",
|
||||||
|
aliasName: "glm-5",
|
||||||
|
modelName: "glm-5",
|
||||||
|
apiBase: "https://api.z.ai/api/coding/paas/v4",
|
||||||
|
wantProvider: "openai",
|
||||||
|
wantModel: "glm-5",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: tt.aliasName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: tt.aliasName,
|
||||||
|
Model: tt.modelName,
|
||||||
|
APIBase: tt.apiBase,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := &mockProvider{}
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
|
if len(agent.Candidates) != 1 {
|
||||||
|
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Provider != tt.wantProvider {
|
||||||
|
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, tt.wantProvider)
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Model != tt.wantModel {
|
||||||
|
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, tt.wantModel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
1501
pkg/agent/loop.go
1501
pkg/agent/loop.go
File diff suppressed because it is too large
Load diff
184
pkg/agent/loop_mcp.go
Normal file
184
pkg/agent/loop_mcp.go
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/mcp"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mcpRuntime struct {
|
||||||
|
initOnce sync.Once
|
||||||
|
mu sync.Mutex
|
||||||
|
manager *mcp.Manager
|
||||||
|
initErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mcpRuntime) setManager(manager *mcp.Manager) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.manager = manager
|
||||||
|
r.initErr = nil
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mcpRuntime) setInitErr(err error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.initErr = err
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mcpRuntime) getInitErr() error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.initErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mcpRuntime) takeManager() *mcp.Manager {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
manager := r.manager
|
||||||
|
r.manager = nil
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mcpRuntime) hasManager() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.manager != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureMCPInitialized loads MCP servers/tools once so both Run() and direct
|
||||||
|
// agent mode share the same initialization path.
|
||||||
|
func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
||||||
|
if !al.cfg.Tools.IsToolEnabled("mcp") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
al.mcp.initOnce.Do(func() {
|
||||||
|
mcpManager := mcp.NewManager()
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
workspacePath := al.cfg.WorkspacePath()
|
||||||
|
if defaultAgent != nil && defaultAgent.Workspace != "" {
|
||||||
|
workspacePath = defaultAgent.Workspace
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
|
||||||
|
map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
if closeErr := mcpManager.Close(); closeErr != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to close MCP manager",
|
||||||
|
map[string]any{
|
||||||
|
"error": closeErr.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register MCP tools for all agents
|
||||||
|
servers := mcpManager.GetServers()
|
||||||
|
uniqueTools := 0
|
||||||
|
totalRegistrations := 0
|
||||||
|
agentIDs := al.registry.ListAgentIDs()
|
||||||
|
agentCount := len(agentIDs)
|
||||||
|
|
||||||
|
for serverName, conn := range servers {
|
||||||
|
uniqueTools += len(conn.Tools)
|
||||||
|
for _, tool := range conn.Tools {
|
||||||
|
for _, agentID := range agentIDs {
|
||||||
|
agent, ok := al.registry.GetAgent(agentID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
|
||||||
|
|
||||||
|
if al.cfg.Tools.MCP.Discovery.Enabled {
|
||||||
|
agent.Tools.RegisterHidden(mcpTool)
|
||||||
|
} else {
|
||||||
|
agent.Tools.Register(mcpTool)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRegistrations++
|
||||||
|
logger.DebugCF("agent", "Registered MCP tool",
|
||||||
|
map[string]any{
|
||||||
|
"agent_id": agentID,
|
||||||
|
"server": serverName,
|
||||||
|
"tool": tool.Name,
|
||||||
|
"name": mcpTool.Name(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.InfoCF("agent", "MCP tools registered successfully",
|
||||||
|
map[string]any{
|
||||||
|
"server_count": len(servers),
|
||||||
|
"unique_tools": uniqueTools,
|
||||||
|
"total_registrations": totalRegistrations,
|
||||||
|
"agent_count": agentCount,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initializes Discovery Tools only if enabled by configuration
|
||||||
|
if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled {
|
||||||
|
useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25
|
||||||
|
useRegex := al.cfg.Tools.MCP.Discovery.UseRegex
|
||||||
|
|
||||||
|
// Fail fast: If discovery is enabled but no search method is turned on
|
||||||
|
if !useBM25 && !useRegex {
|
||||||
|
al.mcp.setInitErr(fmt.Errorf(
|
||||||
|
"tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration",
|
||||||
|
))
|
||||||
|
if closeErr := mcpManager.Close(); closeErr != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to close MCP manager",
|
||||||
|
map[string]any{
|
||||||
|
"error": closeErr.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := al.cfg.Tools.MCP.Discovery.TTL
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 5 // Default value
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults
|
||||||
|
if maxSearchResults <= 0 {
|
||||||
|
maxSearchResults = 5 // Default value
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Initializing tool discovery", map[string]any{
|
||||||
|
"bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults,
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, agentID := range agentIDs {
|
||||||
|
agent, ok := al.registry.GetAgent(agentID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if useRegex {
|
||||||
|
agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults))
|
||||||
|
}
|
||||||
|
if useBM25 {
|
||||||
|
agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
al.mcp.setManager(mcpManager)
|
||||||
|
})
|
||||||
|
|
||||||
|
return al.mcp.getInitErr()
|
||||||
|
}
|
||||||
122
pkg/agent/loop_media.go
Normal file
122
pkg/agent/loop_media.go
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/h2non/filetype"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolveMediaRefs replaces media:// refs in message Media fields with base64 data URLs.
|
||||||
|
// Uses streaming base64 encoding (file handle → encoder → buffer) to avoid holding
|
||||||
|
// both raw bytes and encoded string in memory simultaneously.
|
||||||
|
// Returns a new slice; original messages are not mutated.
|
||||||
|
func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message {
|
||||||
|
if store == nil {
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]providers.Message, len(messages))
|
||||||
|
copy(result, messages)
|
||||||
|
|
||||||
|
for i, m := range result {
|
||||||
|
if len(m.Media) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := make([]string, 0, len(m.Media))
|
||||||
|
for _, ref := range m.Media {
|
||||||
|
if !strings.HasPrefix(ref, "media://") {
|
||||||
|
resolved = append(resolved, ref)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath, meta, err := store.ResolveWithMeta(ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to resolve media ref", map[string]any{
|
||||||
|
"ref": ref,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(localPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to stat media file", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.Size() > int64(maxSize) {
|
||||||
|
logger.WarnCF("agent", "Media file too large, skipping", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"size": info.Size(),
|
||||||
|
"max_size": maxSize,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine MIME type: prefer metadata, fallback to magic-bytes detection
|
||||||
|
mime := meta.ContentType
|
||||||
|
if mime == "" {
|
||||||
|
kind, ftErr := filetype.MatchFile(localPath)
|
||||||
|
if ftErr != nil || kind == filetype.Unknown {
|
||||||
|
logger.WarnCF("agent", "Unknown media type, skipping", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mime = kind.MIME.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaming base64: open file → base64 encoder → buffer
|
||||||
|
// Peak memory: ~1.33x file size (buffer only, no raw bytes copy)
|
||||||
|
f, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to open media file", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := "data:" + mime + ";base64,"
|
||||||
|
encodedLen := base64.StdEncoding.EncodedLen(int(info.Size()))
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.Grow(len(prefix) + encodedLen)
|
||||||
|
buf.WriteString(prefix)
|
||||||
|
|
||||||
|
encoder := base64.NewEncoder(base64.StdEncoding, &buf)
|
||||||
|
if _, err := io.Copy(encoder, f); err != nil {
|
||||||
|
f.Close()
|
||||||
|
logger.WarnCF("agent", "Failed to encode media file", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
encoder.Close()
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
resolved = append(resolved, buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
result[i].Media = resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
@ -5,25 +5,40 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRecordLastChannel(t *testing.T) {
|
type fakeChannel struct{ id string }
|
||||||
// Create temp workspace
|
|
||||||
|
func (f *fakeChannel) Name() string { return "fake" }
|
||||||
|
func (f *fakeChannel) Start(ctx context.Context) error { return nil }
|
||||||
|
func (f *fakeChannel) Stop(ctx context.Context) error { return nil }
|
||||||
|
func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil }
|
||||||
|
func (f *fakeChannel) IsRunning() bool { return true }
|
||||||
|
func (f *fakeChannel) IsAllowed(string) bool { return true }
|
||||||
|
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
|
||||||
|
func (f *fakeChannel) ReasoningChannelID() string { return f.id }
|
||||||
|
|
||||||
|
func newTestAgentLoop(
|
||||||
|
t *testing.T,
|
||||||
|
) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) {
|
||||||
|
t.Helper()
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
cfg = &config.Config{
|
||||||
|
|
||||||
// Create test config
|
|
||||||
cfg := &config.Config{
|
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
|
@ -33,74 +48,43 @@ func TestRecordLastChannel(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
msgBus = bus.NewMessageBus()
|
||||||
|
provider = &mockProvider{}
|
||||||
|
al = NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) }
|
||||||
|
}
|
||||||
|
|
||||||
// Create agent loop
|
func TestRecordLastChannel(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)
|
||||||
provider := &mockProvider{}
|
defer cleanup()
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
|
||||||
|
|
||||||
// Test RecordLastChannel
|
|
||||||
testChannel := "test-channel"
|
testChannel := "test-channel"
|
||||||
err = al.RecordLastChannel(testChannel)
|
if err := al.RecordLastChannel(testChannel); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("RecordLastChannel failed: %v", err)
|
t.Fatalf("RecordLastChannel failed: %v", err)
|
||||||
}
|
}
|
||||||
|
if got := al.state.GetLastChannel(); got != testChannel {
|
||||||
// Verify channel was saved
|
t.Errorf("Expected channel '%s', got '%s'", testChannel, got)
|
||||||
lastChannel := al.state.GetLastChannel()
|
|
||||||
if lastChannel != testChannel {
|
|
||||||
t.Errorf("Expected channel '%s', got '%s'", testChannel, lastChannel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify persistence by creating a new agent loop
|
|
||||||
al2 := NewAgentLoop(cfg, msgBus, provider)
|
al2 := NewAgentLoop(cfg, msgBus, provider)
|
||||||
if al2.state.GetLastChannel() != testChannel {
|
if got := al2.state.GetLastChannel(); got != testChannel {
|
||||||
t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, al2.state.GetLastChannel())
|
t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecordLastChatID(t *testing.T) {
|
func TestRecordLastChatID(t *testing.T) {
|
||||||
// Create temp workspace
|
al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
defer cleanup()
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(tmpDir)
|
|
||||||
|
|
||||||
// Create test config
|
|
||||||
cfg := &config.Config{
|
|
||||||
Agents: config.AgentsConfig{
|
|
||||||
Defaults: config.AgentDefaults{
|
|
||||||
Workspace: tmpDir,
|
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
|
||||||
MaxToolIterations: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create agent loop
|
|
||||||
msgBus := bus.NewMessageBus()
|
|
||||||
provider := &mockProvider{}
|
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
|
||||||
|
|
||||||
// Test RecordLastChatID
|
|
||||||
testChatID := "test-chat-id-123"
|
testChatID := "test-chat-id-123"
|
||||||
err = al.RecordLastChatID(testChatID)
|
if err := al.RecordLastChatID(testChatID); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("RecordLastChatID failed: %v", err)
|
t.Fatalf("RecordLastChatID failed: %v", err)
|
||||||
}
|
}
|
||||||
|
if got := al.state.GetLastChatID(); got != testChatID {
|
||||||
// Verify chat ID was saved
|
t.Errorf("Expected chat ID '%s', got '%s'", testChatID, got)
|
||||||
lastChatID := al.state.GetLastChatID()
|
|
||||||
if lastChatID != testChatID {
|
|
||||||
t.Errorf("Expected chat ID '%s', got '%s'", testChatID, lastChatID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify persistence by creating a new agent loop
|
|
||||||
al2 := NewAgentLoop(cfg, msgBus, provider)
|
al2 := NewAgentLoop(cfg, msgBus, provider)
|
||||||
if al2.state.GetLastChatID() != testChatID {
|
if got := al2.state.GetLastChatID(); got != testChatID {
|
||||||
t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, al2.state.GetLastChatID())
|
t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,47 +159,27 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
|
||||||
toolsList := toolsInfo["names"].([]string)
|
toolsList := toolsInfo["names"].([]string)
|
||||||
|
|
||||||
// Check that our custom tool name is in the list
|
// Check that our custom tool name is in the list
|
||||||
found := false
|
found := slices.Contains(toolsList, "mock_custom")
|
||||||
for _, name := range toolsList {
|
|
||||||
if name == "mock_custom" {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
if !found {
|
||||||
t.Error("Expected custom tool to be registered")
|
t.Error("Expected custom tool to be registered")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestToolContext_Updates verifies tool context is updated with channel/chatID
|
// TestToolContext_Updates verifies tool context helpers work correctly
|
||||||
func TestToolContext_Updates(t *testing.T) {
|
func TestToolContext_Updates(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
ctx := tools.WithToolContext(context.Background(), "telegram", "chat-42")
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(tmpDir)
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
if got := tools.ToolChannel(ctx); got != "telegram" {
|
||||||
Agents: config.AgentsConfig{
|
t.Errorf("expected channel 'telegram', got %q", got)
|
||||||
Defaults: config.AgentDefaults{
|
}
|
||||||
Workspace: tmpDir,
|
if got := tools.ToolChatID(ctx); got != "chat-42" {
|
||||||
Model: "test-model",
|
t.Errorf("expected chatID 'chat-42', got %q", got)
|
||||||
MaxTokens: 4096,
|
|
||||||
MaxToolIterations: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
// Empty context returns empty strings
|
||||||
provider := &simpleMockProvider{response: "OK"}
|
if got := tools.ToolChannel(context.Background()); got != "" {
|
||||||
_ = NewAgentLoop(cfg, msgBus, provider)
|
t.Errorf("expected empty channel from bare context, got %q", got)
|
||||||
|
}
|
||||||
// Verify that ContextualTool interface is defined and can be implemented
|
|
||||||
// This test validates the interface contract exists
|
|
||||||
ctxTool := &mockContextualTool{}
|
|
||||||
|
|
||||||
// Verify the tool implements the interface correctly
|
|
||||||
var _ tools.ContextualTool = ctxTool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved
|
// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved
|
||||||
|
|
@ -250,13 +214,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
|
||||||
toolsList := toolsInfo["names"].([]string)
|
toolsList := toolsInfo["names"].([]string)
|
||||||
|
|
||||||
// Check that our custom tool name is in the list
|
// Check that our custom tool name is in the list
|
||||||
found := false
|
found := slices.Contains(toolsList, "mock_custom")
|
||||||
for _, name := range toolsList {
|
|
||||||
if name == "mock_custom" {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
if !found {
|
||||||
t.Error("Expected custom tool to be registered")
|
t.Error("Expected custom tool to be registered")
|
||||||
}
|
}
|
||||||
|
|
@ -270,16 +228,11 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := config.DefaultConfig()
|
||||||
Agents: config.AgentsConfig{
|
cfg.Agents.Defaults.Workspace = tmpDir
|
||||||
Defaults: config.AgentDefaults{
|
cfg.Agents.Defaults.Model = "test-model"
|
||||||
Workspace: tmpDir,
|
cfg.Agents.Defaults.MaxTokens = 4096
|
||||||
Model: "test-model",
|
cfg.Agents.Defaults.MaxToolIterations = 10
|
||||||
MaxTokens: 4096,
|
|
||||||
MaxToolIterations: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &mockProvider{}
|
provider := &mockProvider{}
|
||||||
|
|
@ -366,6 +319,29 @@ func (m *simpleMockProvider) GetDefaultModel() string {
|
||||||
return "mock-model"
|
return "mock-model"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type countingMockProvider struct {
|
||||||
|
response string
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countingMockProvider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []providers.Message,
|
||||||
|
tools []providers.ToolDefinition,
|
||||||
|
model string,
|
||||||
|
opts map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
m.calls++
|
||||||
|
return &providers.LLMResponse{
|
||||||
|
Content: m.response,
|
||||||
|
ToolCalls: []providers.ToolCall{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countingMockProvider) GetDefaultModel() string {
|
||||||
|
return "counting-mock-model"
|
||||||
|
}
|
||||||
|
|
||||||
// mockCustomTool is a simple mock tool for registration testing
|
// mockCustomTool is a simple mock tool for registration testing
|
||||||
type mockCustomTool struct{}
|
type mockCustomTool struct{}
|
||||||
|
|
||||||
|
|
@ -388,36 +364,6 @@ func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tool
|
||||||
return tools.SilentResult("Custom tool executed")
|
return tools.SilentResult("Custom tool executed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// mockContextualTool tracks context updates
|
|
||||||
type mockContextualTool struct {
|
|
||||||
lastChannel string
|
|
||||||
lastChatID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Name() string {
|
|
||||||
return "mock_contextual"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Description() string {
|
|
||||||
return "Mock contextual tool"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Parameters() map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
|
||||||
return tools.SilentResult("Contextual tool executed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) SetContext(channel, chatID string) {
|
|
||||||
m.lastChannel = channel
|
|
||||||
m.lastChatID = chatID
|
|
||||||
}
|
|
||||||
|
|
||||||
// testHelper executes a message and returns the response
|
// testHelper executes a message and returns the response
|
||||||
type testHelper struct {
|
type testHelper struct {
|
||||||
al *AgentLoop
|
al *AgentLoop
|
||||||
|
|
@ -437,6 +383,198 @@ func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, ms
|
||||||
|
|
||||||
const responseTimeout = 3 * time.Second
|
const responseTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
func TestProcessMessage_UsesRouteSessionKey(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &simpleMockProvider{response: "ok"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
msg := bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "hello",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
route := al.registry.ResolveRoute(routing.RouteInput{
|
||||||
|
Channel: msg.Channel,
|
||||||
|
Peer: extractPeer(msg),
|
||||||
|
})
|
||||||
|
sessionKey := route.SessionKey
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
if defaultAgent == nil {
|
||||||
|
t.Fatal("No default agent found")
|
||||||
|
}
|
||||||
|
|
||||||
|
helper := testHelper{al: al}
|
||||||
|
_ = helper.executeAndGetResponse(t, context.Background(), msg)
|
||||||
|
|
||||||
|
history := defaultAgent.Sessions.GetHistory(sessionKey)
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("expected session history len=2, got %d", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Role != "user" || history[0].Content != "hello" {
|
||||||
|
t.Fatalf("unexpected first message in session: %+v", history[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_CommandOutcomes(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Session: config.SessionConfig{
|
||||||
|
DMScope: "per-channel-peer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &countingMockProvider{response: "LLM reply"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
|
baseMsg := bus.InboundMessage{
|
||||||
|
Channel: "whatsapp",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: baseMsg.Channel,
|
||||||
|
SenderID: baseMsg.SenderID,
|
||||||
|
ChatID: baseMsg.ChatID,
|
||||||
|
Content: "/show channel",
|
||||||
|
Peer: baseMsg.Peer,
|
||||||
|
})
|
||||||
|
if showResp != "Current Channel: whatsapp" {
|
||||||
|
t.Fatalf("unexpected /show reply: %q", showResp)
|
||||||
|
}
|
||||||
|
if provider.calls != 0 {
|
||||||
|
t.Fatalf("LLM should not be called for handled command, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
fooResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: baseMsg.Channel,
|
||||||
|
SenderID: baseMsg.SenderID,
|
||||||
|
ChatID: baseMsg.ChatID,
|
||||||
|
Content: "/foo",
|
||||||
|
Peer: baseMsg.Peer,
|
||||||
|
})
|
||||||
|
if fooResp != "LLM reply" {
|
||||||
|
t.Fatalf("unexpected /foo reply: %q", fooResp)
|
||||||
|
}
|
||||||
|
if provider.calls != 1 {
|
||||||
|
t.Fatalf("LLM should be called exactly once after /foo passthrough, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: baseMsg.Channel,
|
||||||
|
SenderID: baseMsg.SenderID,
|
||||||
|
ChatID: baseMsg.ChatID,
|
||||||
|
Content: "/new",
|
||||||
|
Peer: baseMsg.Peer,
|
||||||
|
})
|
||||||
|
if newResp != "LLM reply" {
|
||||||
|
t.Fatalf("unexpected /new reply: %q", newResp)
|
||||||
|
}
|
||||||
|
if provider.calls != 2 {
|
||||||
|
t.Fatalf("LLM should be called for passthrough /new command, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Provider: "openai",
|
||||||
|
Model: "before-switch",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &countingMockProvider{response: "LLM reply"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
|
switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "/switch model to after-switch",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !strings.Contains(switchResp, "Switched model from before-switch to after-switch") {
|
||||||
|
t.Fatalf("unexpected /switch reply: %q", switchResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "/show model",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !strings.Contains(showResp, "Current Model: after-switch (Provider: openai)") {
|
||||||
|
t.Fatalf("unexpected /show model reply after switch: %q", showResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider.calls != 0 {
|
||||||
|
t.Fatalf("LLM should not be called for /switch and /show, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
|
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
|
||||||
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
|
@ -631,3 +769,400 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
||||||
t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
|
t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessDirectWithChannel_InitializesMCPInAgentMode(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
MCP: config.MCPConfig{
|
||||||
|
ToolConfig: config.ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &mockProvider{}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
defer al.Close()
|
||||||
|
|
||||||
|
if al.mcp.hasManager() {
|
||||||
|
t.Fatal("expected MCP manager to be nil before first direct processing")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = al.ProcessDirectWithChannel(
|
||||||
|
context.Background(),
|
||||||
|
"hello",
|
||||||
|
"session-1",
|
||||||
|
"cli",
|
||||||
|
"direct",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !al.mcp.hasManager() {
|
||||||
|
t.Fatal("expected MCP manager to be initialized in direct agent mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
|
||||||
|
chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create channel manager: %v", err)
|
||||||
|
}
|
||||||
|
for name, id := range map[string]string{
|
||||||
|
"whatsapp": "rid-whatsapp",
|
||||||
|
"telegram": "rid-telegram",
|
||||||
|
"feishu": "rid-feishu",
|
||||||
|
"discord": "rid-discord",
|
||||||
|
"maixcam": "rid-maixcam",
|
||||||
|
"qq": "rid-qq",
|
||||||
|
"dingtalk": "rid-dingtalk",
|
||||||
|
"slack": "rid-slack",
|
||||||
|
"line": "rid-line",
|
||||||
|
"onebot": "rid-onebot",
|
||||||
|
"wecom": "rid-wecom",
|
||||||
|
"wecom_app": "rid-wecom-app",
|
||||||
|
} {
|
||||||
|
chManager.RegisterChannel(name, &fakeChannel{id: id})
|
||||||
|
}
|
||||||
|
al.SetChannelManager(chManager)
|
||||||
|
tests := []struct {
|
||||||
|
channel string
|
||||||
|
wantID string
|
||||||
|
}{
|
||||||
|
{channel: "whatsapp", wantID: "rid-whatsapp"},
|
||||||
|
{channel: "telegram", wantID: "rid-telegram"},
|
||||||
|
{channel: "feishu", wantID: "rid-feishu"},
|
||||||
|
{channel: "discord", wantID: "rid-discord"},
|
||||||
|
{channel: "maixcam", wantID: "rid-maixcam"},
|
||||||
|
{channel: "qq", wantID: "rid-qq"},
|
||||||
|
{channel: "dingtalk", wantID: "rid-dingtalk"},
|
||||||
|
{channel: "slack", wantID: "rid-slack"},
|
||||||
|
{channel: "line", wantID: "rid-line"},
|
||||||
|
{channel: "onebot", wantID: "rid-onebot"},
|
||||||
|
{channel: "wecom", wantID: "rid-wecom"},
|
||||||
|
{channel: "wecom_app", wantID: "rid-wecom-app"},
|
||||||
|
{channel: "unknown", wantID: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.channel, func(t *testing.T) {
|
||||||
|
got := al.targetReasoningChannelID(tt.channel)
|
||||||
|
if got != tt.wantID {
|
||||||
|
t.Fatalf("targetReasoningChannelID(%q) = %q, want %q", tt.channel, got, tt.wantID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleReasoning(t *testing.T) {
|
||||||
|
newLoop := func(t *testing.T) (*AgentLoop, *bus.MessageBus) {
|
||||||
|
t.Helper()
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
return NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("skips when any required field is empty", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
al.handleReasoning(context.Background(), "reasoning", "telegram", "")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if msg, ok := msgBus.SubscribeOutbound(ctx); ok {
|
||||||
|
t.Fatalf("expected no outbound message, got %+v", msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("publishes one message for non telegram", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
msg, ok := msgBus.SubscribeOutbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected an outbound message")
|
||||||
|
}
|
||||||
|
if msg.Channel != "slack" || msg.ChatID != "channel-1" || msg.Content != "hello reasoning" {
|
||||||
|
t.Fatalf("unexpected outbound message: %+v", msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("publishes one message for telegram", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
reasoning := "hello telegram reasoning"
|
||||||
|
al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
msg, ok := msgBus.SubscribeOutbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected outbound message")
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Channel != "telegram" {
|
||||||
|
t.Fatalf("expected telegram channel message, got %+v", msg)
|
||||||
|
}
|
||||||
|
if msg.ChatID != "tg-chat" {
|
||||||
|
t.Fatalf("expected chatID tg-chat, got %+v", msg)
|
||||||
|
}
|
||||||
|
if msg.Content != reasoning {
|
||||||
|
t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("expired ctx", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
reasoning := "hello telegram reasoning"
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
al.handleReasoning(ctx, reasoning, "telegram", "tg-chat")
|
||||||
|
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
msg, ok := msgBus.SubscribeOutbound(ctx)
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected no outbound message, got %+v", msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns promptly when bus is full", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
|
||||||
|
// Fill the outbound bus buffer until a publish would block.
|
||||||
|
// Use a short timeout to detect when the buffer is full,
|
||||||
|
// rather than hardcoding the buffer size.
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
fillCtx, fillCancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
err := msgBus.PublishOutbound(fillCtx, bus.OutboundMessage{
|
||||||
|
Channel: "filler",
|
||||||
|
ChatID: "filler",
|
||||||
|
Content: fmt.Sprintf("filler-%d", i),
|
||||||
|
})
|
||||||
|
fillCancel()
|
||||||
|
if err != nil {
|
||||||
|
// Buffer is full (timed out trying to send).
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a short-deadline parent context to bound the test.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
al.handleReasoning(ctx, "should timeout", "slack", "channel-full")
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// handleReasoning uses a 5s internal timeout, but the parent ctx
|
||||||
|
// expires in 500ms. It should return within ~500ms, not 5s.
|
||||||
|
if elapsed > 2*time.Second {
|
||||||
|
t.Fatalf("handleReasoning blocked too long (%v); expected prompt return", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain the bus and verify the reasoning message was NOT published
|
||||||
|
// (it should have been dropped due to timeout).
|
||||||
|
drainCtx, drainCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer drainCancel()
|
||||||
|
foundReasoning := false
|
||||||
|
for {
|
||||||
|
msg, ok := msgBus.SubscribeOutbound(drainCtx)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if msg.Content == "should timeout" {
|
||||||
|
foundReasoning = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if foundReasoning {
|
||||||
|
t.Fatal("expected reasoning message to be dropped when bus is full, but it was published")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) {
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a minimal valid PNG (8-byte header is enough for filetype detection)
|
||||||
|
pngPath := filepath.Join(dir, "test.png")
|
||||||
|
// PNG magic: 0x89 P N G \r \n 0x1A \n + minimal IHDR
|
||||||
|
pngHeader := []byte{
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||||
|
0x00, 0x00, 0x00, 0x0D, // IHDR length
|
||||||
|
0x49, 0x48, 0x44, 0x52, // "IHDR"
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, // 1x1 RGB
|
||||||
|
0x00, 0x00, 0x00, // no interlace
|
||||||
|
0x90, 0x77, 0x53, 0xDE, // CRC
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(pngPath, pngHeader, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ref, err := store.Store(pngPath, media.MediaMeta{}, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := []providers.Message{
|
||||||
|
{Role: "user", Content: "describe this", Media: []string{ref}},
|
||||||
|
}
|
||||||
|
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||||
|
|
||||||
|
if len(result[0].Media) != 1 {
|
||||||
|
t.Fatalf("expected 1 resolved media, got %d", len(result[0].Media))
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") {
|
||||||
|
t.Fatalf("expected data:image/png;base64, prefix, got %q", result[0].Media[0][:40])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) {
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
bigPath := filepath.Join(dir, "big.png")
|
||||||
|
// Write PNG header + padding to exceed limit
|
||||||
|
data := make([]byte, 1024+1) // 1KB + 1 byte
|
||||||
|
copy(data, []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})
|
||||||
|
if err := os.WriteFile(bigPath, data, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ref, _ := store.Store(bigPath, media.MediaMeta{}, "test")
|
||||||
|
|
||||||
|
messages := []providers.Message{
|
||||||
|
{Role: "user", Content: "hi", Media: []string{ref}},
|
||||||
|
}
|
||||||
|
// Use a tiny limit (1KB) so the file is oversized
|
||||||
|
result := resolveMediaRefs(messages, store, 1024)
|
||||||
|
|
||||||
|
if len(result[0].Media) != 0 {
|
||||||
|
t.Fatalf("expected 0 media (oversized), got %d", len(result[0].Media))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMediaRefs_SkipsUnknownType(t *testing.T) {
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
txtPath := filepath.Join(dir, "readme.txt")
|
||||||
|
if err := os.WriteFile(txtPath, []byte("hello world"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ref, _ := store.Store(txtPath, media.MediaMeta{}, "test")
|
||||||
|
|
||||||
|
messages := []providers.Message{
|
||||||
|
{Role: "user", Content: "hi", Media: []string{ref}},
|
||||||
|
}
|
||||||
|
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||||
|
|
||||||
|
if len(result[0].Media) != 0 {
|
||||||
|
t.Fatalf("expected 0 media (unknown type), got %d", len(result[0].Media))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMediaRefs_PassesThroughNonMediaRefs(t *testing.T) {
|
||||||
|
messages := []providers.Message{
|
||||||
|
{Role: "user", Content: "hi", Media: []string{"https://example.com/img.png"}},
|
||||||
|
}
|
||||||
|
result := resolveMediaRefs(messages, nil, config.DefaultMaxMediaSize)
|
||||||
|
|
||||||
|
if len(result[0].Media) != 1 || result[0].Media[0] != "https://example.com/img.png" {
|
||||||
|
t.Fatalf("expected passthrough of non-media:// URL, got %v", result[0].Media)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMediaRefs_DoesNotMutateOriginal(t *testing.T) {
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
dir := t.TempDir()
|
||||||
|
pngPath := filepath.Join(dir, "test.png")
|
||||||
|
pngHeader := []byte{
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||||
|
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02,
|
||||||
|
0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE,
|
||||||
|
}
|
||||||
|
os.WriteFile(pngPath, pngHeader, 0o644)
|
||||||
|
ref, _ := store.Store(pngPath, media.MediaMeta{}, "test")
|
||||||
|
|
||||||
|
original := []providers.Message{
|
||||||
|
{Role: "user", Content: "hi", Media: []string{ref}},
|
||||||
|
}
|
||||||
|
originalRef := original[0].Media[0]
|
||||||
|
|
||||||
|
resolveMediaRefs(original, store, config.DefaultMaxMediaSize)
|
||||||
|
|
||||||
|
if original[0].Media[0] != originalRef {
|
||||||
|
t.Fatal("resolveMediaRefs mutated original message slice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) {
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// File with JPEG content but stored with explicit content type
|
||||||
|
jpegPath := filepath.Join(dir, "photo")
|
||||||
|
jpegHeader := []byte{0xFF, 0xD8, 0xFF, 0xE0} // JPEG magic bytes
|
||||||
|
os.WriteFile(jpegPath, jpegHeader, 0o644)
|
||||||
|
ref, _ := store.Store(jpegPath, media.MediaMeta{ContentType: "image/jpeg"}, "test")
|
||||||
|
|
||||||
|
messages := []providers.Message{
|
||||||
|
{Role: "user", Content: "hi", Media: []string{ref}},
|
||||||
|
}
|
||||||
|
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||||
|
|
||||||
|
if len(result[0].Media) != 1 {
|
||||||
|
t.Fatalf("expected 1 media, got %d", len(result[0].Media))
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(result[0].Media[0], "data:image/jpeg;base64,") {
|
||||||
|
t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MemoryStore manages persistent memory for the agent.
|
// MemoryStore manages persistent memory for the agent.
|
||||||
|
|
@ -58,7 +60,9 @@ func (ms *MemoryStore) ReadLongTerm() string {
|
||||||
|
|
||||||
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
|
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
|
||||||
func (ms *MemoryStore) WriteLongTerm(content string) error {
|
func (ms *MemoryStore) WriteLongTerm(content string) error {
|
||||||
return os.WriteFile(ms.memoryFile, []byte(content), 0o644)
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
// Using 0o600 (owner read/write only) for secure default permissions.
|
||||||
|
return fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadToday reads today's daily note.
|
// ReadToday reads today's daily note.
|
||||||
|
|
@ -78,7 +82,9 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
||||||
|
|
||||||
// Ensure month directory exists
|
// Ensure month directory exists
|
||||||
monthDir := filepath.Dir(todayFile)
|
monthDir := filepath.Dir(todayFile)
|
||||||
os.MkdirAll(monthDir, 0o755)
|
if err := os.MkdirAll(monthDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
var existingContent string
|
var existingContent string
|
||||||
if data, err := os.ReadFile(todayFile); err == nil {
|
if data, err := os.ReadFile(todayFile); err == nil {
|
||||||
|
|
@ -95,7 +101,8 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
||||||
newContent = existingContent + "\n" + content
|
newContent = existingContent + "\n" + content
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(todayFile, []byte(newContent), 0o644)
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRecentDailyNotes returns daily notes from the last N days.
|
// GetRecentDailyNotes returns daily notes from the last N days.
|
||||||
|
|
@ -104,7 +111,7 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
first := true
|
first := true
|
||||||
|
|
||||||
for i := 0; i < days; i++ {
|
for i := range days {
|
||||||
date := time.Now().AddDate(0, 0, -i)
|
date := time.Now().AddDate(0, 0, -i)
|
||||||
dateStr := date.Format("20060102") // YYYYMMDD
|
dateStr := date.Format("20060102") // YYYYMMDD
|
||||||
monthDir := dateStr[:6] // YYYYMM
|
monthDir := dateStr[:6] // YYYYMM
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentRegistry manages multiple agent instances and routes messages to them.
|
// AgentRegistry manages multiple agent instances and routes messages to them.
|
||||||
|
|
@ -100,6 +101,31 @@ func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bo
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForEachTool calls fn for every tool registered under the given name
|
||||||
|
// across all agents. This is useful for propagating dependencies (e.g.
|
||||||
|
// MediaStore) to tools after registry construction.
|
||||||
|
func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
for _, agent := range r.agents {
|
||||||
|
if t, ok := agent.Tools.Get(name); ok {
|
||||||
|
fn(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases resources held by all registered agents.
|
||||||
|
func (r *AgentRegistry) Close() {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
for _, agent := range r.agents {
|
||||||
|
if err := agent.Close(); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to close agent",
|
||||||
|
map[string]any{"agent_id": agent.ID, "error": err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetDefaultAgent returns the default agent instance.
|
// GetDefaultAgent returns the default agent instance.
|
||||||
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
|
||||||
39
pkg/agent/thinking.go
Normal file
39
pkg/agent/thinking.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// ThinkingLevel controls how the provider sends thinking parameters.
|
||||||
|
//
|
||||||
|
// - "adaptive": sends {thinking: {type: "adaptive"}} + output_config.effort (Claude 4.6+)
|
||||||
|
// - "low"/"medium"/"high"/"xhigh": sends {thinking: {type: "enabled", budget_tokens: N}} (all models)
|
||||||
|
// - "off": disables thinking
|
||||||
|
type ThinkingLevel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ThinkingOff ThinkingLevel = "off"
|
||||||
|
ThinkingLow ThinkingLevel = "low"
|
||||||
|
ThinkingMedium ThinkingLevel = "medium"
|
||||||
|
ThinkingHigh ThinkingLevel = "high"
|
||||||
|
ThinkingXHigh ThinkingLevel = "xhigh"
|
||||||
|
ThinkingAdaptive ThinkingLevel = "adaptive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseThinkingLevel normalizes a config string to a ThinkingLevel.
|
||||||
|
// Case-insensitive and whitespace-tolerant for user-facing config values.
|
||||||
|
// Returns ThinkingOff for unknown or empty values.
|
||||||
|
func parseThinkingLevel(level string) ThinkingLevel {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||||
|
case "adaptive":
|
||||||
|
return ThinkingAdaptive
|
||||||
|
case "low":
|
||||||
|
return ThinkingLow
|
||||||
|
case "medium":
|
||||||
|
return ThinkingMedium
|
||||||
|
case "high":
|
||||||
|
return ThinkingHigh
|
||||||
|
case "xhigh":
|
||||||
|
return ThinkingXHigh
|
||||||
|
default:
|
||||||
|
return ThinkingOff
|
||||||
|
}
|
||||||
|
}
|
||||||
35
pkg/agent/thinking_test.go
Normal file
35
pkg/agent/thinking_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseThinkingLevel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want ThinkingLevel
|
||||||
|
}{
|
||||||
|
{"off", "off", ThinkingOff},
|
||||||
|
{"empty", "", ThinkingOff},
|
||||||
|
{"low", "low", ThinkingLow},
|
||||||
|
{"medium", "medium", ThinkingMedium},
|
||||||
|
{"high", "high", ThinkingHigh},
|
||||||
|
{"xhigh", "xhigh", ThinkingXHigh},
|
||||||
|
{"adaptive", "adaptive", ThinkingAdaptive},
|
||||||
|
{"unknown", "unknown", ThinkingOff},
|
||||||
|
// Case-insensitive and whitespace-tolerant
|
||||||
|
{"upper_Medium", "Medium", ThinkingMedium},
|
||||||
|
{"upper_HIGH", "HIGH", ThinkingHigh},
|
||||||
|
{"mixed_Adaptive", "Adaptive", ThinkingAdaptive},
|
||||||
|
{"leading_space", " high", ThinkingHigh},
|
||||||
|
{"trailing_space", "low ", ThinkingLow},
|
||||||
|
{"both_spaces", " medium ", ThinkingMedium},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := parseThinkingLevel(tt.input); got != tt.want {
|
||||||
|
t.Errorf("parseThinkingLevel(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
71
pkg/auth/anthropic_usage.go
Normal file
71
pkg/auth/anthropic_usage.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
anthropicBetaHeader = "oauth-2025-04-20"
|
||||||
|
anthropicAPIVersion = "2023-06-01"
|
||||||
|
)
|
||||||
|
|
||||||
|
// anthropicUsageURL is the endpoint for fetching OAuth usage stats.
|
||||||
|
// It is a var (not const) to allow overriding in tests.
|
||||||
|
var anthropicUsageURL = "https://api.anthropic.com/api/oauth/usage"
|
||||||
|
|
||||||
|
func setAnthropicUsageURL(url string) { anthropicUsageURL = url }
|
||||||
|
|
||||||
|
type AnthropicUsage struct {
|
||||||
|
FiveHourUtilization float64
|
||||||
|
SevenDayUtilization float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchAnthropicUsage(token string) (*AnthropicUsage, error) {
|
||||||
|
req, err := http.NewRequest("GET", anthropicUsageURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Anthropic-Version", anthropicAPIVersion)
|
||||||
|
req.Header.Set("Anthropic-Beta", anthropicBetaHeader)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading usage response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if resp.StatusCode == http.StatusForbidden {
|
||||||
|
return nil, fmt.Errorf("insufficient scope: usage endpoint requires oauth scope")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("usage request failed (%d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
FiveHour struct {
|
||||||
|
Utilization float64 `json:"utilization"`
|
||||||
|
} `json:"five_hour"`
|
||||||
|
SevenDay struct {
|
||||||
|
Utilization float64 `json:"utilization"`
|
||||||
|
} `json:"seven_day"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing usage response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AnthropicUsage{
|
||||||
|
FiveHourUtilization: result.FiveHour.Utilization,
|
||||||
|
SevenDayUtilization: result.SevenDay.Utilization,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
98
pkg/auth/anthropic_usage_test.go
Normal file
98
pkg/auth/anthropic_usage_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetchAnthropicUsage_Success(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
|
||||||
|
t.Errorf("Authorization = %q, want %q", got, "Bearer test-token")
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Anthropic-Beta"); got != anthropicBetaHeader {
|
||||||
|
t.Errorf("Anthropic-Beta = %q, want %q", got, anthropicBetaHeader)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"five_hour":{"utilization":0.42},"seven_day":{"utilization":0.85}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Temporarily override the URL by using the test server
|
||||||
|
origURL := anthropicUsageURL
|
||||||
|
defer func() { setAnthropicUsageURL(origURL) }()
|
||||||
|
setAnthropicUsageURL(srv.URL)
|
||||||
|
|
||||||
|
usage, err := FetchAnthropicUsage("test-token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if usage.FiveHourUtilization != 0.42 {
|
||||||
|
t.Errorf("FiveHourUtilization = %v, want 0.42", usage.FiveHourUtilization)
|
||||||
|
}
|
||||||
|
if usage.SevenDayUtilization != 0.85 {
|
||||||
|
t.Errorf("SevenDayUtilization = %v, want 0.85", usage.SevenDayUtilization)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAnthropicUsage_Forbidden(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`{"error":"forbidden"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origURL := anthropicUsageURL
|
||||||
|
defer func() { setAnthropicUsageURL(origURL) }()
|
||||||
|
setAnthropicUsageURL(srv.URL)
|
||||||
|
|
||||||
|
_, err := FetchAnthropicUsage("test-token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 403, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "insufficient scope") {
|
||||||
|
t.Errorf("expected 'insufficient scope' error, got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAnthropicUsage_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(`internal error`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origURL := anthropicUsageURL
|
||||||
|
defer func() { setAnthropicUsageURL(origURL) }()
|
||||||
|
setAnthropicUsageURL(srv.URL)
|
||||||
|
|
||||||
|
_, err := FetchAnthropicUsage("test-token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 500, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "500") {
|
||||||
|
t.Errorf("expected error containing '500', got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAnthropicUsage_MalformedJSON(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`not json`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origURL := anthropicUsageURL
|
||||||
|
defer func() { setAnthropicUsageURL(origURL) }()
|
||||||
|
setAnthropicUsageURL(srv.URL)
|
||||||
|
|
||||||
|
_, err := FetchAnthropicUsage("test-token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for malformed JSON, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "parsing usage response") {
|
||||||
|
t.Errorf("expected 'parsing usage response' error, got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -66,7 +66,8 @@ func decodeBase64(s string) string {
|
||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateState() (string, error) {
|
// GenerateState generates a random state string for OAuth CSRF protection.
|
||||||
|
func GenerateState() (string, error) {
|
||||||
buf := make([]byte, 32)
|
buf := make([]byte, 32)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -80,7 +81,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
return nil, fmt.Errorf("generating PKCE: %w", err)
|
return nil, fmt.Errorf("generating PKCE: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
state, err := generateState()
|
state, err := GenerateState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("generating state: %w", err)
|
return nil, fmt.Errorf("generating state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -127,7 +128,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
|
|
||||||
fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL)
|
fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL)
|
||||||
|
|
||||||
if err := openBrowser(authURL); err != nil {
|
if err := OpenBrowser(authURL); err != nil {
|
||||||
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,7 +154,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
if result.err != nil {
|
if result.err != nil {
|
||||||
return nil, result.err
|
return nil, result.err
|
||||||
}
|
}
|
||||||
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
return ExchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
||||||
case manualInput := <-manualCh:
|
case manualInput := <-manualCh:
|
||||||
if manualInput == "" {
|
if manualInput == "" {
|
||||||
return nil, fmt.Errorf("manual input canceled")
|
return nil, fmt.Errorf("manual input canceled")
|
||||||
|
|
@ -169,7 +170,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
if code == "" {
|
if code == "" {
|
||||||
return nil, fmt.Errorf("could not find authorization code in input")
|
return nil, fmt.Errorf("could not find authorization code in input")
|
||||||
}
|
}
|
||||||
return exchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
|
return ExchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
|
||||||
case <-time.After(5 * time.Minute):
|
case <-time.After(5 * time.Minute):
|
||||||
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
||||||
}
|
}
|
||||||
|
|
@ -186,6 +187,62 @@ type deviceCodeResponse struct {
|
||||||
Interval int
|
Interval int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeviceCodeInfo holds the device code information returned by the OAuth provider.
|
||||||
|
type DeviceCodeInfo struct {
|
||||||
|
DeviceAuthID string `json:"device_auth_id"`
|
||||||
|
UserCode string `json:"user_code"`
|
||||||
|
VerifyURL string `json:"verify_url"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestDeviceCode requests a device code from the OAuth provider.
|
||||||
|
// Returns the info needed for the user to authenticate in a browser.
|
||||||
|
func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) {
|
||||||
|
reqBody, _ := json.Marshal(map[string]string{
|
||||||
|
"client_id": cfg.ClientID,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := http.Post(
|
||||||
|
cfg.Issuer+"/api/accounts/deviceauth/usercode",
|
||||||
|
"application/json",
|
||||||
|
strings.NewReader(string(reqBody)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("requesting device code: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading device code response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceResp, err := parseDeviceCodeResponse(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing device code response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if deviceResp.Interval < 1 {
|
||||||
|
deviceResp.Interval = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DeviceCodeInfo{
|
||||||
|
DeviceAuthID: deviceResp.DeviceAuthID,
|
||||||
|
UserCode: deviceResp.UserCode,
|
||||||
|
VerifyURL: cfg.Issuer + "/codex/device",
|
||||||
|
Interval: deviceResp.Interval,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollDeviceCodeOnce makes a single poll attempt to check if the user has authenticated.
|
||||||
|
// Returns (credential, nil) on success, (nil, nil) if still pending, or (nil, err) on failure.
|
||||||
|
func PollDeviceCodeOnce(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) {
|
||||||
|
return pollDeviceCode(cfg, deviceAuthID, userCode)
|
||||||
|
}
|
||||||
|
|
||||||
func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
|
func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
|
||||||
var raw struct {
|
var raw struct {
|
||||||
DeviceAuthID string `json:"device_auth_id"`
|
DeviceAuthID string `json:"device_auth_id"`
|
||||||
|
|
@ -246,7 +303,10 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading device code response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -306,7 +366,10 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au
|
||||||
return nil, fmt.Errorf("pending")
|
return nil, fmt.Errorf("pending")
|
||||||
}
|
}
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading device token response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var tokenResp struct {
|
var tokenResp struct {
|
||||||
AuthorizationCode string `json:"authorization_code"`
|
AuthorizationCode string `json:"authorization_code"`
|
||||||
|
|
@ -318,7 +381,7 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURI := cfg.Issuer + "/deviceauth/callback"
|
redirectURI := cfg.Issuer + "/deviceauth/callback"
|
||||||
return exchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
|
return ExchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
|
||||||
}
|
}
|
||||||
|
|
||||||
func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) {
|
func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
|
|
@ -347,7 +410,10 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading token refresh response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -410,7 +476,8 @@ func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU
|
||||||
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
|
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
|
// ExchangeCodeForTokens exchanges an authorization code for tokens.
|
||||||
|
func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
|
||||||
data := url.Values{
|
data := url.Values{
|
||||||
"grant_type": {"authorization_code"},
|
"grant_type": {"authorization_code"},
|
||||||
"code": {code},
|
"code": {code},
|
||||||
|
|
@ -439,7 +506,10 @@ func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading token exchange response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -552,7 +622,8 @@ func base64URLDecode(s string) ([]byte, error) {
|
||||||
return base64.StdEncoding.DecodeString(s)
|
return base64.StdEncoding.DecodeString(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func openBrowser(url string) error {
|
// OpenBrowser opens the given URL in the user's default browser.
|
||||||
|
func OpenBrowser(url string) error {
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
return exec.Command("open", url).Start()
|
return exec.Command("open", url).Start()
|
||||||
|
|
|
||||||
|
|
@ -219,9 +219,9 @@ func TestExchangeCodeForTokens(t *testing.T) {
|
||||||
Port: 1455,
|
Port: 1455,
|
||||||
}
|
}
|
||||||
|
|
||||||
cred, err := exchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback")
|
cred, err := ExchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("exchangeCodeForTokens() error: %v", err)
|
t.Fatalf("ExchangeCodeForTokens() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cred.AccessToken != "mock-access-token" {
|
if cred.AccessToken != "mock-access-token" {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthCredential struct {
|
type AuthCredential struct {
|
||||||
|
|
@ -37,6 +39,9 @@ func (c *AuthCredential) NeedsRefresh() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func authFilePath() string {
|
func authFilePath() string {
|
||||||
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
|
return filepath.Join(home, "auth.json")
|
||||||
|
}
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
return filepath.Join(home, ".picoclaw", "auth.json")
|
return filepath.Join(home, ".picoclaw", "auth.json")
|
||||||
}
|
}
|
||||||
|
|
@ -63,16 +68,13 @@ func LoadStore() (*AuthStore, error) {
|
||||||
|
|
||||||
func SaveStore(store *AuthStore) error {
|
func SaveStore(store *AuthStore) error {
|
||||||
path := authFilePath()
|
path := authFilePath()
|
||||||
dir := filepath.Dir(path)
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.MarshalIndent(store, "", " ")
|
data, err := json.MarshalIndent(store, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(path, data, 0o600)
|
|
||||||
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCredential(provider string) (*AuthCredential, error) {
|
func GetCredential(provider string) (*AuthCredential, error) {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,35 @@ func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LoginSetupToken(r io.Reader) (*AuthCredential, error) {
|
||||||
|
fmt.Println("Paste your setup token from `claude setup-token`:")
|
||||||
|
fmt.Print("> ")
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
if !scanner.Scan() {
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("reading token: %w", err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no input received")
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimSpace(scanner.Text())
|
||||||
|
|
||||||
|
if !strings.HasPrefix(token, "sk-ant-oat01-") {
|
||||||
|
return nil, fmt.Errorf("invalid setup token: expected prefix sk-ant-oat01-")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(token) < 80 {
|
||||||
|
return nil, fmt.Errorf("invalid setup token: too short (expected at least 80 characters)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AuthCredential{
|
||||||
|
AccessToken: token,
|
||||||
|
Provider: "anthropic",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func providerDisplayName(provider string) string {
|
func providerDisplayName(provider string) string {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
|
|
|
||||||
61
pkg/auth/token_test.go
Normal file
61
pkg/auth/token_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoginSetupToken(t *testing.T) {
|
||||||
|
// A valid token: correct prefix + at least 80 chars
|
||||||
|
validToken := "sk-ant-oat01-" + strings.Repeat("a", 80)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{"valid token", validToken, ""},
|
||||||
|
{"empty input", "", "expected prefix sk-ant-oat01-"},
|
||||||
|
{"wrong prefix", "sk-ant-api-" + strings.Repeat("a", 80), "expected prefix sk-ant-oat01-"},
|
||||||
|
{"too short", "sk-ant-oat01-short", "too short"},
|
||||||
|
{"whitespace only", " ", "expected prefix sk-ant-oat01-"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := strings.NewReader(tt.input + "\n")
|
||||||
|
cred, err := LoginSetupToken(r)
|
||||||
|
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cred.AccessToken != validToken {
|
||||||
|
t.Errorf("AccessToken = %q, want %q", cred.AccessToken, validToken)
|
||||||
|
}
|
||||||
|
if cred.Provider != "anthropic" {
|
||||||
|
t.Errorf("Provider = %q, want %q", cred.Provider, "anthropic")
|
||||||
|
}
|
||||||
|
if cred.AuthMethod != "oauth" {
|
||||||
|
t.Errorf("AuthMethod = %q, want %q", cred.AuthMethod, "oauth")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginSetupToken_EmptyReader(t *testing.T) {
|
||||||
|
r := strings.NewReader("")
|
||||||
|
_, err := LoginSetupToken(r)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty reader, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
157
pkg/bus/bus.go
157
pkg/bus/bus.go
|
|
@ -2,81 +2,156 @@ package bus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sync"
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrBusClosed is returned when publishing to a closed MessageBus.
|
||||||
|
var ErrBusClosed = errors.New("message bus closed")
|
||||||
|
|
||||||
|
const defaultBusBufferSize = 64
|
||||||
|
|
||||||
type MessageBus struct {
|
type MessageBus struct {
|
||||||
inbound chan InboundMessage
|
inbound chan InboundMessage
|
||||||
outbound chan OutboundMessage
|
outbound chan OutboundMessage
|
||||||
handlers map[string]MessageHandler
|
outboundMedia chan OutboundMediaMessage
|
||||||
closed bool
|
done chan struct{}
|
||||||
mu sync.RWMutex
|
closed atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMessageBus() *MessageBus {
|
func NewMessageBus() *MessageBus {
|
||||||
return &MessageBus{
|
return &MessageBus{
|
||||||
inbound: make(chan InboundMessage, 100),
|
inbound: make(chan InboundMessage, defaultBusBufferSize),
|
||||||
outbound: make(chan OutboundMessage, 100),
|
outbound: make(chan OutboundMessage, defaultBusBufferSize),
|
||||||
handlers: make(map[string]MessageHandler),
|
outboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize),
|
||||||
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishInbound(msg InboundMessage) {
|
func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {
|
||||||
mb.mu.RLock()
|
if mb.closed.Load() {
|
||||||
defer mb.mu.RUnlock()
|
return ErrBusClosed
|
||||||
if mb.closed {
|
}
|
||||||
return
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case mb.inbound <- msg:
|
||||||
|
return nil
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
mb.inbound <- msg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) {
|
func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) {
|
||||||
select {
|
select {
|
||||||
case msg := <-mb.inbound:
|
case msg, ok := <-mb.inbound:
|
||||||
return msg, true
|
return msg, ok
|
||||||
|
case <-mb.done:
|
||||||
|
return InboundMessage{}, false
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return InboundMessage{}, false
|
return InboundMessage{}, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishOutbound(msg OutboundMessage) {
|
func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {
|
||||||
mb.mu.RLock()
|
if mb.closed.Load() {
|
||||||
defer mb.mu.RUnlock()
|
return ErrBusClosed
|
||||||
if mb.closed {
|
}
|
||||||
return
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case mb.outbound <- msg:
|
||||||
|
return nil
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
mb.outbound <- msg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) {
|
func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) {
|
||||||
select {
|
select {
|
||||||
case msg := <-mb.outbound:
|
case msg, ok := <-mb.outbound:
|
||||||
return msg, true
|
return msg, ok
|
||||||
|
case <-mb.done:
|
||||||
|
return OutboundMessage{}, false
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return OutboundMessage{}, false
|
return OutboundMessage{}, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) RegisterHandler(channel string, handler MessageHandler) {
|
func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {
|
||||||
mb.mu.Lock()
|
if mb.closed.Load() {
|
||||||
defer mb.mu.Unlock()
|
return ErrBusClosed
|
||||||
mb.handlers[channel] = handler
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case mb.outboundMedia <- msg:
|
||||||
|
return nil
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) GetHandler(channel string) (MessageHandler, bool) {
|
func (mb *MessageBus) SubscribeOutboundMedia(ctx context.Context) (OutboundMediaMessage, bool) {
|
||||||
mb.mu.RLock()
|
select {
|
||||||
defer mb.mu.RUnlock()
|
case msg, ok := <-mb.outboundMedia:
|
||||||
handler, ok := mb.handlers[channel]
|
return msg, ok
|
||||||
return handler, ok
|
case <-mb.done:
|
||||||
|
return OutboundMediaMessage{}, false
|
||||||
|
case <-ctx.Done():
|
||||||
|
return OutboundMediaMessage{}, false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) Close() {
|
func (mb *MessageBus) Close() {
|
||||||
mb.mu.Lock()
|
if mb.closed.CompareAndSwap(false, true) {
|
||||||
defer mb.mu.Unlock()
|
close(mb.done)
|
||||||
if mb.closed {
|
|
||||||
return
|
// Drain buffered channels so messages aren't silently lost.
|
||||||
|
// Channels are NOT closed to avoid send-on-closed panics from concurrent publishers.
|
||||||
|
drained := 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mb.inbound:
|
||||||
|
drained++
|
||||||
|
default:
|
||||||
|
goto doneInbound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doneInbound:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mb.outbound:
|
||||||
|
drained++
|
||||||
|
default:
|
||||||
|
goto doneOutbound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doneOutbound:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mb.outboundMedia:
|
||||||
|
drained++
|
||||||
|
default:
|
||||||
|
goto doneMedia
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doneMedia:
|
||||||
|
if drained > 0 {
|
||||||
|
logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{
|
||||||
|
"count": drained,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
mb.closed = true
|
|
||||||
close(mb.inbound)
|
|
||||||
close(mb.outbound)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
229
pkg/bus/bus_test.go
Normal file
229
pkg/bus/bus_test.go
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
package bus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublishConsume(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
msg := InboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "hello",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishInbound(ctx, msg); err != nil {
|
||||||
|
t.Fatalf("PublishInbound failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := mb.ConsumeInbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ConsumeInbound returned ok=false")
|
||||||
|
}
|
||||||
|
if got.Content != "hello" {
|
||||||
|
t.Fatalf("expected content 'hello', got %q", got.Content)
|
||||||
|
}
|
||||||
|
if got.Channel != "test" {
|
||||||
|
t.Fatalf("expected channel 'test', got %q", got.Channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishOutboundSubscribe(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
msg := OutboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
ChatID: "123",
|
||||||
|
Content: "world",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishOutbound(ctx, msg); err != nil {
|
||||||
|
t.Fatalf("PublishOutbound failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := mb.SubscribeOutbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("SubscribeOutbound returned ok=false")
|
||||||
|
}
|
||||||
|
if got.Content != "world" {
|
||||||
|
t.Fatalf("expected content 'world', got %q", got.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInbound_ContextCancel(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
// Fill the buffer
|
||||||
|
ctx := context.Background()
|
||||||
|
for i := range defaultBusBufferSize {
|
||||||
|
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
|
||||||
|
t.Fatalf("fill failed at %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now buffer is full; publish with a canceled context
|
||||||
|
cancelCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := mb.PublishInbound(cancelCtx, InboundMessage{Content: "overflow"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error from canceled context, got nil")
|
||||||
|
}
|
||||||
|
if err != context.Canceled {
|
||||||
|
t.Fatalf("expected context.Canceled, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInbound_BusClosed(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"})
|
||||||
|
if err != ErrBusClosed {
|
||||||
|
t.Fatalf("expected ErrBusClosed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishOutbound_BusClosed(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
err := mb.PublishOutbound(context.Background(), OutboundMessage{Content: "test"})
|
||||||
|
if err != ErrBusClosed {
|
||||||
|
t.Fatalf("expected ErrBusClosed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeInbound_ContextCancel(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, ok := mb.ConsumeInbound(ctx)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected ok=false when context is canceled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeInbound_BusClosed(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, ok := mb.ConsumeInbound(ctx)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected ok=false when bus is closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscribeOutbound_BusClosed(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, ok := mb.SubscribeOutbound(ctx)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected ok=false when bus is closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentPublishClose(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const numGoroutines = 100
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(numGoroutines + 1)
|
||||||
|
|
||||||
|
// Spawn many goroutines trying to publish
|
||||||
|
for range numGoroutines {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
// Use a short timeout context so we don't block forever after close
|
||||||
|
publishCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
// Errors are expected; we just must not panic or deadlock
|
||||||
|
_ = mb.PublishInbound(publishCtx, InboundMessage{Content: "concurrent"})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close from another goroutine
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
mb.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Must complete without deadlock
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// success
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("test timed out - possible deadlock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInbound_FullBuffer(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Fill the buffer
|
||||||
|
for i := range defaultBusBufferSize {
|
||||||
|
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
|
||||||
|
t.Fatalf("fill failed at %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer is full; publish with short timeout
|
||||||
|
timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := mb.PublishInbound(timeoutCtx, InboundMessage{Content: "overflow"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when buffer is full and context times out")
|
||||||
|
}
|
||||||
|
if err != context.DeadlineExceeded {
|
||||||
|
t.Fatalf("expected context.DeadlineExceeded, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseIdempotent(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
|
||||||
|
// Multiple Close calls must not panic
|
||||||
|
mb.Close()
|
||||||
|
mb.Close()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
// After close, publish should return ErrBusClosed
|
||||||
|
err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"})
|
||||||
|
if err != ErrBusClosed {
|
||||||
|
t.Fatalf("expected ErrBusClosed after multiple closes, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue