Compare commits
No commits in common. "main" and "v0.2.6" have entirely different histories.
916 changed files with 20312 additions and 110774 deletions
|
|
@ -1,5 +1,3 @@
|
||||||
# Do NOT exclude LICENSE or .github — scripts/copydir.go uses them as repo-root anchors
|
|
||||||
# during `go generate`, which runs inside `make build` in the Dockerfile.
|
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
build/
|
build/
|
||||||
|
|
@ -8,4 +6,5 @@ config/
|
||||||
.env
|
.env
|
||||||
.env.example
|
.env.example
|
||||||
*.md
|
*.md
|
||||||
|
LICENSE
|
||||||
assets/
|
assets/
|
||||||
|
|
|
||||||
3
.gitattributes
vendored
3
.gitattributes
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
# Ensure shell scripts always use LF line endings regardless of OS.
|
|
||||||
*.sh text eol=lf
|
|
||||||
docker/entrypoint.sh text eol=lf
|
|
||||||
13
.github/workflows/build.yml
vendored
13
.github/workflows/build.yml
vendored
|
|
@ -5,18 +5,7 @@ on:
|
||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
integration:
|
|
||||||
name: Integration Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Run Docker-backed integration suites
|
|
||||||
run: bash ./scripts/run-integration-tests.sh
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: integration
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|
@ -27,5 +16,5 @@ jobs:
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Build core binaries
|
- name: Build
|
||||||
run: make build-all
|
run: make build-all
|
||||||
|
|
|
||||||
60
.github/workflows/create-tag.yml
vendored
60
.github/workflows/create-tag.yml
vendored
|
|
@ -1,60 +0,0 @@
|
||||||
name: Create Tag
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: "Tag name (required, e.g. v0.2.0)"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
commit:
|
|
||||||
description: "Target commit SHA (leave empty for latest main)"
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
create-tag:
|
|
||||||
name: Create Git Tag
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: main
|
|
||||||
|
|
||||||
- name: Validate commit exists
|
|
||||||
if: ${{ inputs.commit != '' }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
if ! git cat-file -t "${{ inputs.commit }}" &>/dev/null; then
|
|
||||||
echo "::error::Commit '${{ inputs.commit }}' does not exist."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Check tag does not already exist
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
if gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then
|
|
||||||
echo "::error::Tag '${{ inputs.tag }}' already exists."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create and push tag
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
TARGET="${{ inputs.commit || 'HEAD' }}"
|
|
||||||
COMMIT_SHA=$(git rev-parse "$TARGET")
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git tag -a "${{ inputs.tag }}" "$COMMIT_SHA" -m "Release ${{ inputs.tag }}"
|
|
||||||
git push origin "${{ inputs.tag }}"
|
|
||||||
echo "### Tag Created" >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
echo "- **Tag:** \`${{ inputs.tag }}\`" >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
echo "- **Commit:** \`${COMMIT_SHA}\`" >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
echo "- **Branch:** \`$(git branch -r --contains "$COMMIT_SHA" | head -1 | xargs)\`" >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
27
.github/workflows/create_dmg.yml
vendored
27
.github/workflows/create_dmg.yml
vendored
|
|
@ -17,38 +17,29 @@ jobs:
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
# 1. Install Go from go.mod
|
# 1. 安装指定版本的 Go (可选,但推荐)
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Setup pnpm
|
# 2. 安装 pnpm
|
||||||
uses: pnpm/action-setup@v6
|
- name: Install pnpm
|
||||||
with:
|
run: brew install pnpm
|
||||||
version: 10.33.0
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
# 3. 运行你的 Makefile 编译二进制文件
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
|
||||||
|
|
||||||
# 3. Build the application bundle
|
|
||||||
- name: Build with Make
|
- name: Build with Make
|
||||||
run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }}
|
run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }}
|
||||||
|
|
||||||
# 4. Apply ad-hoc signing
|
# 4. 签名
|
||||||
- name: Ad-hoc Sign
|
- name: Ad-hoc Sign
|
||||||
run: codesign --force --deep --sign - "build/PicoClaw Launcher.app"
|
run: codesign --force --deep --sign - "build/PicoClaw Launcher.app"
|
||||||
|
|
||||||
# 5. Install the DMG packaging tool
|
# 5. 安装打包工具
|
||||||
- name: Install create-dmg
|
- name: Install create-dmg
|
||||||
run: brew install create-dmg
|
run: brew install create-dmg
|
||||||
|
|
||||||
# 6. Create the DMG
|
# 6. 执行打包命令
|
||||||
- name: Create DMG
|
- name: Create DMG
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
|
|
@ -63,7 +54,7 @@ jobs:
|
||||||
"dist/picoclaw-${{ matrix.arch }}.dmg" \
|
"dist/picoclaw-${{ matrix.arch }}.dmg" \
|
||||||
"build/PicoClaw Launcher.app"
|
"build/PicoClaw Launcher.app"
|
||||||
|
|
||||||
# 7. Upload the DMG as a GitHub artifact
|
# 7. 上传文件到 GitHub Artifacts (供你下载)
|
||||||
- name: Upload DMG
|
- name: Upload DMG
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
18
.github/workflows/nightly.yml
vendored
18
.github/workflows/nightly.yml
vendored
|
|
@ -47,18 +47,13 @@ jobs:
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v6
|
|
||||||
with:
|
|
||||||
version: 10.33.0
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
- name: Setup pnpm
|
||||||
|
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
@ -80,9 +75,6 @@ jobs:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Install zip
|
|
||||||
run: sudo apt-get install -y zip
|
|
||||||
|
|
||||||
- name: Create local tag for GoReleaser
|
- name: Create local tag for GoReleaser
|
||||||
run: git tag "${{ steps.version.outputs.version }}"
|
run: git tag "${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
|
|
@ -98,7 +90,6 @@ jobs:
|
||||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||||
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
|
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
|
||||||
INCLUDE_ANDROID_BUNDLE: "true"
|
|
||||||
NIGHTLY_BUILD: "true"
|
NIGHTLY_BUILD: "true"
|
||||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||||
|
|
@ -132,7 +123,7 @@ jobs:
|
||||||
|
|
||||||
# Collect release artifacts from goreleaser dist/
|
# Collect release artifacts from goreleaser dist/
|
||||||
ASSETS=()
|
ASSETS=()
|
||||||
for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt build/picoclaw-android-universal.zip; do
|
for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do
|
||||||
[ -f "$f" ] && ASSETS+=("$f")
|
[ -f "$f" ] && ASSETS+=("$f")
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
@ -144,3 +135,4 @@ jobs:
|
||||||
--prerelease \
|
--prerelease \
|
||||||
--latest=false \
|
--latest=false \
|
||||||
"${ASSETS[@]}"
|
"${ASSETS[@]}"
|
||||||
|
|
||||||
|
|
|
||||||
10
.github/workflows/pr.yml
vendored
10
.github/workflows/pr.yml
vendored
|
|
@ -64,13 +64,3 @@ jobs:
|
||||||
|
|
||||||
- name: Run go test
|
- name: Run go test
|
||||||
run: go test -tags goolm,stdjson ./...
|
run: go test -tags goolm,stdjson ./...
|
||||||
|
|
||||||
integration:
|
|
||||||
name: Integration Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Run Docker-backed integration suites
|
|
||||||
run: bash ./scripts/run-integration-tests.sh
|
|
||||||
|
|
|
||||||
51
.github/workflows/release.yml
vendored
51
.github/workflows/release.yml
vendored
|
|
@ -1,10 +1,10 @@
|
||||||
name: Release
|
name: Create Tag and Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
description: "Existing tag to release (e.g. v0.2.0)"
|
description: "Release tag (required, e.g. v0.2.0)"
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
prerelease:
|
prerelease:
|
||||||
|
|
@ -24,23 +24,35 @@ on:
|
||||||
default: true
|
default: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
create-tag:
|
||||||
|
name: Create Git Tag
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Create and push tag
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ inputs.tag }}
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG"
|
||||||
|
git push origin "$RELEASE_TAG"
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: GoReleaser Release
|
name: GoReleaser Release
|
||||||
|
needs: create-tag
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Verify tag exists
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
if ! gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then
|
|
||||||
echo "::error::Tag '${{ inputs.tag }}' does not exist. Create it first using the 'Create Tag' workflow."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Checkout tag
|
- name: Checkout tag
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -53,18 +65,13 @@ jobs:
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v6
|
|
||||||
with:
|
|
||||||
version: 10.33.0
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
- name: Setup pnpm
|
||||||
|
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
@ -86,9 +93,6 @@ jobs:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Install zip
|
|
||||||
run: sudo apt-get install -y zip
|
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v7
|
uses: goreleaser/goreleaser-action@v7
|
||||||
with:
|
with:
|
||||||
|
|
@ -100,7 +104,6 @@ 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 }}
|
||||||
INCLUDE_ANDROID_BUNDLE: "true"
|
|
||||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||||
|
|
|
||||||
64
.github/workflows/stale.yml
vendored
64
.github/workflows/stale.yml
vendored
|
|
@ -1,64 +0,0 @@
|
||||||
name: Close stale issues and PRs
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# Run daily at 03:00 JST (18:00 UTC)
|
|
||||||
- cron: "0 18 * * *"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
stale:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Mark and close stale issues and PRs
|
|
||||||
uses: actions/stale@v10
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
# ── Issue: 7 days inactive → stale; 7 more days → close ──
|
|
||||||
days-before-issue-stale: 7
|
|
||||||
days-before-issue-close: 7
|
|
||||||
stale-issue-label: "stale"
|
|
||||||
stale-issue-message: >
|
|
||||||
This issue has had no activity for 7 days and has been marked as stale.
|
|
||||||
If it is still relevant, please reply or update; otherwise it will be
|
|
||||||
closed automatically in 7 days.
|
|
||||||
close-issue-message: >
|
|
||||||
This issue has been closed after 14 days of inactivity.
|
|
||||||
If it is still needed, feel free to reopen it anytime.
|
|
||||||
close-issue-reason: "not_planned"
|
|
||||||
|
|
||||||
# ── PR: 7 days inactive → stale; 7 more days → close ──
|
|
||||||
days-before-pr-stale: 7
|
|
||||||
days-before-pr-close: 7
|
|
||||||
stale-pr-label: "stale"
|
|
||||||
stale-pr-message: >
|
|
||||||
This PR has had no activity for 7 days and has been marked as stale.
|
|
||||||
If you are still working on it, please push an update or leave a comment;
|
|
||||||
otherwise it will be closed automatically in 7 days.
|
|
||||||
close-pr-message: >
|
|
||||||
This PR has been closed after 14 days of inactivity.
|
|
||||||
If you would like to continue, feel free to reopen it or submit a new PR.
|
|
||||||
|
|
||||||
# ── Protected labels (exempt from stale processing) ──
|
|
||||||
exempt-issue-labels: "pinned,keep-open,wip,do-not-close,type: roadmap"
|
|
||||||
exempt-pr-labels: "pinned,keep-open,wip,do-not-close,type: roadmap"
|
|
||||||
|
|
||||||
# ── Exempt draft PRs ──
|
|
||||||
exempt-draft-pr: true
|
|
||||||
|
|
||||||
# ── Remove stale label when activity resumes ──
|
|
||||||
remove-stale-when-updated: true
|
|
||||||
remove-issue-stale-when-updated: true
|
|
||||||
remove-pr-stale-when-updated: true
|
|
||||||
|
|
||||||
# ── Scan oldest items first so old stale items are not starved ──
|
|
||||||
ascending: true
|
|
||||||
|
|
||||||
# ── Throttle: max operations per run ──
|
|
||||||
operations-per-run: 500
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -55,10 +55,6 @@ dist/
|
||||||
|
|
||||||
# Windows Application Icon/Resource
|
# Windows Application Icon/Resource
|
||||||
*.syso
|
*.syso
|
||||||
.cache/
|
|
||||||
web/frontend/.pnpm-store/
|
|
||||||
_tmp_*
|
|
||||||
web/frontend/_tmp_*
|
|
||||||
|
|
||||||
# Test telegram integration
|
# Test telegram integration
|
||||||
cmd/telegram/
|
cmd/telegram/
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,11 @@ git:
|
||||||
|
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
|
- go mod tidy
|
||||||
- go generate ./...
|
- go generate ./...
|
||||||
- sh -c 'cd web/frontend && CI=true pnpm install --frozen-lockfile && pnpm build:backend'
|
- sh -c 'cd web/frontend && pnpm install && pnpm build:backend'
|
||||||
- sh -c 'GOBIN="$(go env GOPATH)/bin"; mkdir -p "$GOBIN"; go install github.com/tc-hib/go-winres@v0.3.3 && "$GOBIN/go-winres" make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}'
|
- go install github.com/tc-hib/go-winres@latest
|
||||||
- sh -c 'if [ "${INCLUDE_ANDROID_BUNDLE:-}" = "true" ]; then make build-android-bundle; fi'
|
- 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
|
||||||
|
|
@ -26,7 +27,7 @@ builds:
|
||||||
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
|
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
|
||||||
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
|
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
|
||||||
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
|
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
|
||||||
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
|
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }}
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
|
|
@ -66,10 +67,6 @@ builds:
|
||||||
- stdjson
|
- stdjson
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w
|
- -s -w
|
||||||
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
|
|
||||||
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
|
|
||||||
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
|
|
||||||
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
|
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
|
|
@ -100,6 +97,45 @@ builds:
|
||||||
- goos: netbsd
|
- goos: netbsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
|
||||||
|
- id: picoclaw-launcher-tui
|
||||||
|
binary: picoclaw-launcher-tui
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
tags:
|
||||||
|
- goolm
|
||||||
|
- stdjson
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
- darwin
|
||||||
|
- freebsd
|
||||||
|
- netbsd
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- riscv64
|
||||||
|
- loong64
|
||||||
|
- arm
|
||||||
|
- s390x
|
||||||
|
- mipsle
|
||||||
|
goarm:
|
||||||
|
- "6"
|
||||||
|
- "7"
|
||||||
|
gomips:
|
||||||
|
- softfloat
|
||||||
|
main: ./cmd/picoclaw-launcher-tui
|
||||||
|
ignore:
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
|
- goos: netbsd
|
||||||
|
goarch: s390x
|
||||||
|
- goos: netbsd
|
||||||
|
goarch: mips64
|
||||||
|
- goos: netbsd
|
||||||
|
goarch: arm
|
||||||
|
|
||||||
dockers_v2:
|
dockers_v2:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
dockerfile: docker/Dockerfile.goreleaser
|
dockerfile: docker/Dockerfile.goreleaser
|
||||||
|
|
@ -123,6 +159,7 @@ dockers_v2:
|
||||||
ids:
|
ids:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
- picoclaw-launcher
|
- picoclaw-launcher
|
||||||
|
- picoclaw-launcher-tui
|
||||||
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 }}'
|
||||||
|
|
@ -140,6 +177,7 @@ notarize:
|
||||||
ids:
|
ids:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
- picoclaw-launcher
|
- picoclaw-launcher
|
||||||
|
- picoclaw-launcher-tui
|
||||||
sign:
|
sign:
|
||||||
certificate: "{{.Env.MACOS_SIGN_P12}}"
|
certificate: "{{.Env.MACOS_SIGN_P12}}"
|
||||||
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
|
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
|
||||||
|
|
@ -170,6 +208,7 @@ nfpms:
|
||||||
ids:
|
ids:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
- picoclaw-launcher
|
- picoclaw-launcher
|
||||||
|
- picoclaw-launcher-tui
|
||||||
package_name: picoclaw
|
package_name: picoclaw
|
||||||
file_name_template: >-
|
file_name_template: >-
|
||||||
{{ .PackageName }}_
|
{{ .PackageName }}_
|
||||||
|
|
@ -206,8 +245,6 @@ changelog:
|
||||||
|
|
||||||
release:
|
release:
|
||||||
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
|
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
|
||||||
extra_files:
|
|
||||||
- glob: ./build/picoclaw-android-universal.zip
|
|
||||||
footer: >-
|
footer: >-
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,6 @@ We are committed to maintaining a welcoming and respectful community. Be kind, c
|
||||||
|
|
||||||
For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction.
|
For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction.
|
||||||
|
|
||||||
For documentation contributions, prefer the layout and naming conventions in [`docs/README.md`](docs/README.md). Run `make lint-docs` after adding or moving Markdown files to catch common consistency issues early.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
@ -66,30 +64,26 @@ For documentation contributions, prefer the layout and naming conventions in [`d
|
||||||
```bash
|
```bash
|
||||||
make build # Build binary (runs go generate first)
|
make build # Build binary (runs go generate first)
|
||||||
make generate # Run go generate only
|
make generate # Run go generate only
|
||||||
make check # Full pre-commit check: deps + fmt + vet + test + docs consistency checks
|
make check # Full pre-commit check: deps + fmt + vet + test
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test # Run all tests
|
make test # Run all tests
|
||||||
make integration-test # Run Docker-backed integration suites
|
|
||||||
go test -run TestName -v ./pkg/session/ # Run a single test
|
go test -run TestName -v ./pkg/session/ # Run a single test
|
||||||
go test -bench=. -benchmem -run='^$' ./... # Run benchmarks
|
go test -bench=. -benchmem -run='^$' ./... # Run benchmarks
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker-backed integration suites are auto-discovered from [`integration/suites/`](integration/suites/). See [`integration/README.md`](integration/README.md) for the suite layout and the conventions used by CI.
|
|
||||||
|
|
||||||
### Code Style
|
### Code Style
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make fmt # Format code
|
make fmt # Format code
|
||||||
make vet # Static analysis
|
make vet # Static analysis
|
||||||
make lint # Full linter run
|
make lint # Full linter run
|
||||||
make lint-docs # Check common documentation layout and naming conventions
|
|
||||||
```
|
```
|
||||||
|
|
||||||
All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early, including the common docs consistency checks from `make lint-docs`.
|
All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -114,7 +108,7 @@ Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider
|
||||||
- Reference the related issue when relevant: `Fix session leak (#123)`.
|
- Reference the related issue when relevant: `Fix session leak (#123)`.
|
||||||
- Keep commits focused. One logical change per commit is preferred.
|
- Keep commits focused. One logical change per commit is preferred.
|
||||||
- For minor cleanups or typo fixes, squash them into a single commit before opening a PR.
|
- For minor cleanups or typo fixes, squash them into a single commit before opening a PR.
|
||||||
- Refer to [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
- Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/
|
||||||
|
|
||||||
### Keeping Up to Date
|
### Keeping Up to Date
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ git checkout -b 你的功能分支名
|
||||||
- 有关联 Issue 时请引用:`Fix session leak (#123)`。
|
- 有关联 Issue 时请引用:`Fix session leak (#123)`。
|
||||||
- 保持 commit 专注,每个 commit 只做一件事。
|
- 保持 commit 专注,每个 commit 只做一件事。
|
||||||
- 对于小的清理或拼写修正,提 PR 前请将其合并为一个 commit。
|
- 对于小的清理或拼写修正,提 PR 前请将其合并为一个 commit。
|
||||||
- 按照 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/v1.0.0/) 规范来撰写
|
- 按照 https://www.conventionalcommits.org/zh-hans/v1.0.0/ 规范来撰写
|
||||||
|
|
||||||
### 保持与上游同步
|
### 保持与上游同步
|
||||||
|
|
||||||
163
Makefile
163
Makefile
|
|
@ -1,4 +1,4 @@
|
||||||
.PHONY: all build install uninstall clean help test integration-test build-all lint-docs
|
.PHONY: all build install uninstall clean help test
|
||||||
|
|
||||||
# Build variables
|
# Build variables
|
||||||
BINARY_NAME=picoclaw
|
BINARY_NAME=picoclaw
|
||||||
|
|
@ -7,43 +7,19 @@ CMD_DIR=cmd/$(BINARY_NAME)
|
||||||
MAIN_GO=$(CMD_DIR)/main.go
|
MAIN_GO=$(CMD_DIR)/main.go
|
||||||
EXT=
|
EXT=
|
||||||
|
|
||||||
ifeq ($(OS),Windows_NT)
|
|
||||||
POWERSHELL=powershell -NoProfile -Command
|
|
||||||
WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL))
|
|
||||||
endif
|
|
||||||
|
|
||||||
# Version
|
# Version
|
||||||
ifeq ($(OS),Windows_NT)
|
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL))
|
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||||
GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL))
|
BUILD_TIME=$(shell date +%FT%T%z)
|
||||||
BUILD_TIME_RAW:=$(strip $(shell powershell -NoProfile -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'"))
|
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
||||||
GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>NUL))
|
|
||||||
else
|
|
||||||
VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>/dev/null))
|
|
||||||
GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>/dev/null))
|
|
||||||
BUILD_TIME_RAW:=$(strip $(shell date +%FT%T%z))
|
|
||||||
GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>/dev/null))
|
|
||||||
endif
|
|
||||||
VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev)
|
|
||||||
GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev)
|
|
||||||
BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev)
|
|
||||||
GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown)
|
|
||||||
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||||
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
|
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?=go
|
GO?=CGO_ENABLED=0 go
|
||||||
WEB_GO?=$(GO)
|
WEB_GO?=$(GO)
|
||||||
CGO_ENABLED?=0
|
|
||||||
GO_BUILD_TAGS?=goolm,stdjson
|
GO_BUILD_TAGS?=goolm,stdjson
|
||||||
GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
|
GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
|
||||||
GOCACHE?=$(CURDIR)/.cache/go-build
|
|
||||||
GOMODCACHE?=$(CURDIR)/.cache/go-mod
|
|
||||||
GOTOOLCHAIN?=local
|
|
||||||
export CGO_ENABLED
|
|
||||||
export GOCACHE
|
|
||||||
export GOMODCACHE
|
|
||||||
export GOTOOLCHAIN
|
|
||||||
comma:=,
|
comma:=,
|
||||||
empty:=
|
empty:=
|
||||||
space:=$(empty) $(empty)
|
space:=$(empty) $(empty)
|
||||||
|
|
@ -97,21 +73,8 @@ BUILTIN_SKILLS_DIR=$(CURDIR)/skills
|
||||||
LNCMD=ln -sf
|
LNCMD=ln -sf
|
||||||
|
|
||||||
# OS detection
|
# OS detection
|
||||||
ifeq ($(OS),Windows_NT)
|
UNAME_S?=$(shell uname -s)
|
||||||
UNAME_S=Windows
|
UNAME_M?=$(shell uname -m)
|
||||||
ifeq ($(WINDOWS_GOARCH_RAW),amd64)
|
|
||||||
UNAME_M=x86_64
|
|
||||||
else ifeq ($(WINDOWS_GOARCH_RAW),arm64)
|
|
||||||
UNAME_M=arm64
|
|
||||||
else ifeq ($(WINDOWS_GOARCH_RAW),386)
|
|
||||||
UNAME_M=x86
|
|
||||||
else
|
|
||||||
UNAME_M=$(if $(WINDOWS_GOARCH_RAW),$(WINDOWS_GOARCH_RAW),x86_64)
|
|
||||||
endif
|
|
||||||
else
|
|
||||||
UNAME_S?=$(shell uname -s)
|
|
||||||
UNAME_M?=$(shell uname -m)
|
|
||||||
endif
|
|
||||||
|
|
||||||
# Platform-specific settings
|
# Platform-specific settings
|
||||||
ifeq ($(UNAME_S),Linux)
|
ifeq ($(UNAME_S),Linux)
|
||||||
|
|
@ -159,30 +122,6 @@ else
|
||||||
|
|
||||||
endif
|
endif
|
||||||
|
|
||||||
ifeq ($(OS),Windows_NT)
|
|
||||||
PLATFORM=windows
|
|
||||||
ifeq ($(UNAME_M),x86_64)
|
|
||||||
ARCH?=amd64
|
|
||||||
else ifeq ($(UNAME_M),arm64)
|
|
||||||
ARCH?=arm64
|
|
||||||
else
|
|
||||||
ARCH?=$(UNAME_M)
|
|
||||||
endif
|
|
||||||
EXT=.exe
|
|
||||||
endif
|
|
||||||
|
|
||||||
ifneq ($(strip $(GOOS)),)
|
|
||||||
PLATFORM:=$(GOOS)
|
|
||||||
endif
|
|
||||||
|
|
||||||
ifneq ($(strip $(GOARCH)),)
|
|
||||||
ARCH:=$(GOARCH)
|
|
||||||
endif
|
|
||||||
|
|
||||||
ifeq ($(PLATFORM),windows)
|
|
||||||
EXT=.exe
|
|
||||||
endif
|
|
||||||
|
|
||||||
BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
|
BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
|
|
@ -191,51 +130,41 @@ all: build
|
||||||
## generate: Run generate
|
## generate: Run generate
|
||||||
generate:
|
generate:
|
||||||
@echo "Run generate..."
|
@echo "Run generate..."
|
||||||
ifeq ($(OS),Windows_NT)
|
|
||||||
@$(POWERSHELL) "if (Test-Path -LiteralPath './$(CMD_DIR)/workspace') { Remove-Item -LiteralPath './$(CMD_DIR)/workspace' -Recurse -Force }"
|
|
||||||
@$(POWERSHELL) "$$env:GOOS=''; $$env:GOARCH=''; $(GO) generate ./..."
|
|
||||||
else
|
|
||||||
@rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
|
@rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
|
||||||
@GOOS=$$($(GO) env GOHOSTOS) GOARCH=$$($(GO) env GOHOSTARCH) $(GO) generate ./...
|
@$(GO) generate ./...
|
||||||
endif
|
|
||||||
@echo "Run generate complete"
|
@echo "Run generate complete"
|
||||||
|
|
||||||
## build: Build the picoclaw binary for current platform
|
## build: Build the picoclaw binary for current platform
|
||||||
build: generate
|
build: generate
|
||||||
@echo "Building $(BINARY_NAME)$(EXT) for $(PLATFORM)/$(ARCH)..."
|
@echo "Building $(BINARY_NAME)$(EXT) for $(PLATFORM)/$(ARCH)..."
|
||||||
ifeq ($(OS),Windows_NT)
|
|
||||||
@$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
|
|
||||||
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR)
|
|
||||||
@$(POWERSHELL) "Copy-Item -LiteralPath '$(BINARY_PATH)$(EXT)' -Destination '$(BUILD_DIR)/$(BINARY_NAME)$(EXT)' -Force"
|
|
||||||
else
|
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
@GOOS=$(PLATFORM) GOARCH=$(ARCH) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR)
|
@GOARCH=${ARCH} $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR)
|
||||||
@echo "Build complete: $(BINARY_PATH)$(EXT)"
|
@echo "Build complete: $(BINARY_PATH)$(EXT)"
|
||||||
@$(LNCMD) $(BINARY_NAME)-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/$(BINARY_NAME)$(EXT)
|
@$(LNCMD) $(BINARY_NAME)-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/$(BINARY_NAME)$(EXT)
|
||||||
endif
|
|
||||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)$(EXT)"
|
|
||||||
|
|
||||||
## build-launcher: Build the picoclaw-launcher (web console) binary
|
## build-launcher: Build the picoclaw-launcher (web console) binary
|
||||||
build-launcher:
|
build-launcher:
|
||||||
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
||||||
ifeq ($(OS),Windows_NT)
|
|
||||||
@$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
|
|
||||||
@$(MAKE) -C web build PLATFORM="$(PLATFORM)" ARCH="$(ARCH)" EXT="$(EXT)" OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" GO_BUILD_TAGS="$(GO_BUILD_TAGS)"
|
|
||||||
@$(POWERSHELL) "Copy-Item -LiteralPath '$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)' -Destination '$(BUILD_DIR)/picoclaw-launcher$(EXT)' -Force"
|
|
||||||
else
|
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
@GOOS=$(PLATFORM) GOARCH=$(ARCH) $(MAKE) -C web build \
|
@GOARCH=${ARCH} $(MAKE) -C web build \
|
||||||
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" \
|
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" \
|
||||||
WEB_GO='$(WEB_GO)' \
|
WEB_GO='$(WEB_GO)' \
|
||||||
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
|
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
|
||||||
LDFLAGS='$(LDFLAGS)'
|
LDFLAGS='$(LDFLAGS)'
|
||||||
@$(LNCMD) picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/picoclaw-launcher$(EXT)
|
@$(LNCMD) picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/picoclaw-launcher$(EXT)
|
||||||
endif
|
|
||||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher$(EXT)"
|
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher$(EXT)"
|
||||||
|
|
||||||
build-launcher-frontend:
|
build-launcher-frontend:
|
||||||
@$(MAKE) -C web build-frontend
|
@$(MAKE) -C web build-frontend
|
||||||
|
|
||||||
|
## build-launcher-tui: Build the picoclaw-launcher TUI binary
|
||||||
|
build-launcher-tui:
|
||||||
|
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) ./cmd/picoclaw-launcher-tui
|
||||||
|
@ln -sf picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher-tui
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-tui"
|
||||||
|
|
||||||
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||||
build-whatsapp-native: generate
|
build-whatsapp-native: generate
|
||||||
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||||
|
|
@ -276,44 +205,11 @@ build-linux-mipsle: generate
|
||||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
|
||||||
|
|
||||||
## build-android-arm64: Build core for Android ARM64
|
|
||||||
build-android-arm64: generate
|
|
||||||
@echo "Building for android/arm64..."
|
|
||||||
@mkdir -p $(BUILD_DIR)
|
|
||||||
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
|
|
||||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-android-arm64"
|
|
||||||
|
|
||||||
## build-launcher-android-arm64: Build launcher for Android ARM64
|
|
||||||
build-launcher-android-arm64:
|
|
||||||
@echo "Building picoclaw-launcher for android/arm64..."
|
|
||||||
@mkdir -p $(BUILD_DIR)
|
|
||||||
@$(MAKE) -C web build-android-arm64 \
|
|
||||||
OUTPUT_ANDROID_ARM64="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64" \
|
|
||||||
GO='$(GO)' \
|
|
||||||
LDFLAGS='$(LDFLAGS)'
|
|
||||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-android-arm64"
|
|
||||||
|
|
||||||
## build-android-bundle: Build core and launcher for all Android architectures and package as universal zip
|
|
||||||
build-android-bundle: generate
|
|
||||||
@echo "Building core for all Android architectures..."
|
|
||||||
@mkdir -p $(BUILD_DIR)
|
|
||||||
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
|
|
||||||
@echo "Building launcher for Android arm64..."
|
|
||||||
@$(MAKE) build-launcher-android-arm64
|
|
||||||
@echo "Staging JNI libs..."
|
|
||||||
@rm -rf $(BUILD_DIR)/android-staging
|
|
||||||
@mkdir -p $(BUILD_DIR)/android-staging/arm64-v8a
|
|
||||||
@cp $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw.so
|
|
||||||
@cp $(BUILD_DIR)/picoclaw-launcher-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw-web.so
|
|
||||||
@cd $(BUILD_DIR)/android-staging && zip -r ../picoclaw-android-universal.zip .
|
|
||||||
@rm -rf $(BUILD_DIR)/android-staging
|
|
||||||
@echo "All Android builds complete: $(BUILD_DIR)/picoclaw-android-universal.zip"
|
|
||||||
|
|
||||||
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
|
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
|
||||||
build-pi-zero: build-linux-arm build-linux-arm64
|
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)"
|
@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 the picoclaw core binary for all Makefile-managed 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)
|
||||||
|
|
@ -330,7 +226,7 @@ build-all: generate
|
||||||
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
|
GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
|
||||||
GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
|
GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
|
||||||
@echo "Core builds complete"
|
@echo "All builds complete"
|
||||||
|
|
||||||
## install: Install picoclaw to system and copy builtin skills
|
## install: Install picoclaw to system and copy builtin skills
|
||||||
install: build
|
install: build
|
||||||
|
|
@ -361,11 +257,7 @@ uninstall-all:
|
||||||
## clean: Remove build artifacts
|
## clean: Remove build artifacts
|
||||||
clean:
|
clean:
|
||||||
@echo "Cleaning build artifacts..."
|
@echo "Cleaning build artifacts..."
|
||||||
ifeq ($(OS),Windows_NT)
|
|
||||||
@$(POWERSHELL) "if (Test-Path -LiteralPath '$(BUILD_DIR)') { Remove-Item -LiteralPath '$(BUILD_DIR)' -Recurse -Force }"
|
|
||||||
else
|
|
||||||
@rm -rf $(BUILD_DIR)
|
@rm -rf $(BUILD_DIR)
|
||||||
endif
|
|
||||||
@echo "Clean complete"
|
@echo "Clean complete"
|
||||||
|
|
||||||
## vet: Run go vet for static analysis
|
## vet: Run go vet for static analysis
|
||||||
|
|
@ -379,22 +271,13 @@ test: generate
|
||||||
@$(GO) test $(GOFLAGS) $$($(GO) list $(GOFLAGS) ./... | grep -v github.com/sipeed/picoclaw/web/)
|
@$(GO) test $(GOFLAGS) $$($(GO) list $(GOFLAGS) ./... | grep -v github.com/sipeed/picoclaw/web/)
|
||||||
@cd web && make test
|
@cd web && make test
|
||||||
|
|
||||||
## integration-test: Run Docker-backed integration test suites
|
|
||||||
integration-test:
|
|
||||||
@bash ./scripts/run-integration-tests.sh
|
|
||||||
|
|
||||||
## fmt: Format Go code
|
## fmt: Format Go code
|
||||||
fmt:
|
fmt:
|
||||||
@$(GOLANGCI_LINT) fmt
|
@$(GOLANGCI_LINT) fmt
|
||||||
|
|
||||||
## lint-docs: Check common documentation layout and naming conventions
|
|
||||||
lint-docs:
|
|
||||||
@./scripts/lint-docs.sh
|
|
||||||
|
|
||||||
## lint: Run linters
|
## lint: Run linters
|
||||||
lint:
|
lint:
|
||||||
@$(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS)
|
@$(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS)
|
||||||
@./scripts/lint-docs.sh
|
|
||||||
|
|
||||||
## fix: Fix linting issues
|
## fix: Fix linting issues
|
||||||
fix:
|
fix:
|
||||||
|
|
@ -410,8 +293,8 @@ update-deps:
|
||||||
@$(GO) get -u ./...
|
@$(GO) get -u ./...
|
||||||
@$(GO) mod tidy
|
@$(GO) mod tidy
|
||||||
|
|
||||||
## check: Run deps, fmt, vet, tests, and docs consistency checks
|
## check: Run vet, fmt, and verify dependencies
|
||||||
check: deps fmt vet test lint-docs
|
check: deps fmt vet test
|
||||||
|
|
||||||
## run: Build and run picoclaw
|
## run: Build and run picoclaw
|
||||||
run: build
|
run: build
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="../../assets/logo.webp" 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>
|
||||||
|
|
||||||
|
|
@ -14,11 +14,11 @@
|
||||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||||
<br>
|
<br>
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
<a href="../../assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -35,12 +35,12 @@
|
||||||
<tr align="center">
|
<tr align="center">
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/picoclaw_mem.gif" width="360" height="240">
|
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/licheervnano.png" width="400" height="240">
|
<img src="assets/licheervnano.png" width="400" height="240">
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -57,14 +57,6 @@
|
||||||
|
|
||||||
## 📢 Actualités
|
## 📢 Actualités
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw disponible sur AliExpress !** Vous pouvez désormais acheter le LicheeRV-Claw sur [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), ce qui facilite l'essai de PicoClaw sur du matériel RISC-V compact.
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
|
||||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
2026-03-31 📱 **Support Android !** PicoClaw fonctionne maintenant sur Android ! Téléchargez l'APK sur [picoclaw.io](https://picoclaw.io/download)
|
2026-03-31 📱 **Support Android !** PicoClaw fonctionne maintenant sur Android ! Téléchargez l'APK sur [picoclaw.io](https://picoclaw.io/download)
|
||||||
|
|
||||||
2026-03-25 🚀 **v0.2.4 publiée !** Refonte de l'architecture Agent (SubTurn, Hooks, Steering, EventBus), intégration WeChat/WeCom, renforcement de la sécurité (.security.yml, filtrage des données sensibles), nouveaux providers (AWS Bedrock, Azure, Xiaomi MiMo), et 35 corrections de bugs. PicoClaw a atteint **26K Stars** !
|
2026-03-25 🚀 **v0.2.4 publiée !** Refonte de l'architecture Agent (SubTurn, Hooks, Steering, EventBus), intégration WeChat/WeCom, renforcement de la sécurité (.security.yml, filtrage des données sensibles), nouveaux providers (AWS Bedrock, Azure, Xiaomi MiMo), et 35 corrections de bugs. PicoClaw a atteint **26K Stars** !
|
||||||
|
|
@ -80,7 +72,7 @@
|
||||||
|
|
||||||
2026-02-26 🎉 PicoClaw atteint **20K Stars** en seulement 17 jours ! L'orchestration automatique des channels et les interfaces de capacités sont disponibles.
|
2026-02-26 🎉 PicoClaw atteint **20K Stars** en seulement 17 jours ! L'orchestration automatique des channels et les interfaces de capacités sont disponibles.
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw dépasse 12K Stars en une semaine ! Rôles de mainteneurs communautaires et [Roadmap](../../ROADMAP.md) officiellement lancés.
|
2026-02-16 🎉 PicoClaw dépasse 12K Stars en une semaine ! Rôles de mainteneurs communautaires et [Roadmap](ROADMAP.md) officiellement lancés.
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw dépasse 5000 Stars en 4 jours ! Roadmap du projet et groupes de développeurs en cours.
|
2026-02-13 🎉 PicoClaw dépasse 5000 Stars en 4 jours ! Roadmap du projet et groupes de développeurs en cours.
|
||||||
|
|
||||||
|
|
@ -118,14 +110,14 @@ _*Les builds récents peuvent utiliser 10-20 Mo en raison des fusions rapides de
|
||||||
| **Temps de démarrage**</br>(cœur 0,8 GHz) | >500s | >30s | **<1s** |
|
| **Temps de démarrage**</br>(cœur 0,8 GHz) | >500s | >30s | **<1s** |
|
||||||
| **Coût** | Mac Mini $599 | La plupart des cartes Linux ~$50 | **N'importe quelle carte Linux**</br>**à partir de $10** |
|
| **Coût** | Mac Mini $599 | La plupart des cartes Linux ~$50 | **N'importe quelle carte Linux**</br>**à partir de $10** |
|
||||||
|
|
||||||
<img src="../../assets/compare.jpg" alt="PicoClaw" width="512">
|
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **[Liste de compatibilité matérielle](../guides/hardware-compatibility.fr.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR !
|
> **[Liste de compatibilité matérielle](docs/fr/hardware-compatibility.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR !
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 🦾 Démonstration
|
## 🦾 Démonstration
|
||||||
|
|
@ -139,9 +131,9 @@ _*Les builds récents peuvent utiliser 10-20 Mo en raison des fusions rapides de
|
||||||
<th><p align="center">Recherche Web & Apprentissage</p></th>
|
<th><p align="center">Recherche Web & Apprentissage</p></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">Développer · Déployer · Mettre à l'échelle</td>
|
<td align="center">Développer · Déployer · Mettre à l'échelle</td>
|
||||||
|
|
@ -175,27 +167,19 @@ Vous pouvez aussi télécharger le binaire pour votre plateforme depuis la page
|
||||||
|
|
||||||
### Compiler depuis les sources (pour le développement)
|
### Compiler depuis les sources (pour le développement)
|
||||||
|
|
||||||
Prérequis :
|
|
||||||
|
|
||||||
- Go 1.25+
|
|
||||||
- Node.js 22+ et pnpm 10.33.0+ pour les builds Web UI / launcher
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Installer les dépendances frontend
|
|
||||||
(cd web/frontend && pnpm install --frozen-lockfile)
|
|
||||||
|
|
||||||
# Compiler le binaire principal
|
# Compiler le binaire principal
|
||||||
make build
|
make build
|
||||||
|
|
||||||
# Compiler le Web UI Launcher (requis pour le mode WebUI)
|
# Compiler le Web UI Launcher (requis pour le mode WebUI)
|
||||||
make build-launcher
|
make build-launcher
|
||||||
|
|
||||||
# Compiler les binaires core pour toutes les plateformes gérées par le Makefile
|
# Compiler pour plusieurs plateformes
|
||||||
make build-all
|
make build-all
|
||||||
|
|
||||||
# Compiler pour Raspberry Pi Zero 2 W (32 bits : make build-linux-arm ; 64 bits : make build-linux-arm64)
|
# Compiler pour Raspberry Pi Zero 2 W (32 bits : make build-linux-arm ; 64 bits : make build-linux-arm64)
|
||||||
|
|
@ -231,7 +215,7 @@ picoclaw-launcher
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Pour commencer :**
|
**Pour commencer :**
|
||||||
|
|
@ -285,7 +269,7 @@ macOS peut bloquer `picoclaw-launcher` au premier lancement car il est télécha
|
||||||
**Étape 1 :** Double-cliquez sur `picoclaw-launcher`. Un avertissement de sécurité s'affiche :
|
**Étape 1 :** Double-cliquez sur `picoclaw-launcher`. Un avertissement de sécurité s'affiche :
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/macos-gatekeeper-warning.jpg" alt="Avertissement macOS Gatekeeper" width="400">
|
<img src="assets/macos-gatekeeper-warning.jpg" alt="Avertissement macOS Gatekeeper" width="400">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> *"picoclaw-launcher" n'a pas pu être ouvert — Apple n'a pas pu vérifier que "picoclaw-launcher" ne contient pas de logiciel malveillant susceptible de nuire à votre Mac ou de compromettre votre confidentialité.*
|
> *"picoclaw-launcher" n'a pas pu être ouvert — Apple n'a pas pu vérifier que "picoclaw-launcher" ne contient pas de logiciel malveillant susceptible de nuire à votre Mac ou de compromettre votre confidentialité.*
|
||||||
|
|
@ -293,14 +277,31 @@ macOS peut bloquer `picoclaw-launcher` au premier lancement car il est télécha
|
||||||
**Étape 2 :** Ouvrez **Réglages Système** → **Confidentialité et sécurité** → faites défiler jusqu'à la section **Sécurité** → cliquez sur **Ouvrir quand même** → confirmez en cliquant sur **Ouvrir quand même** dans la boîte de dialogue.
|
**Étape 2 :** Ouvrez **Réglages Système** → **Confidentialité et sécurité** → faites défiler jusqu'à la section **Sécurité** → cliquez sur **Ouvrir quand même** → confirmez en cliquant sur **Ouvrir quand même** dans la boîte de dialogue.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/macos-gatekeeper-allow.jpg" alt="macOS Confidentialité et sécurité — Ouvrir quand même" width="600">
|
<img src="assets/macos-gatekeeper-allow.jpg" alt="macOS Confidentialité et sécurité — Ouvrir quand même" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Après cette étape unique, `picoclaw-launcher` s'ouvrira normalement lors des lancements suivants.
|
Après cette étape unique, `picoclaw-launcher` s'ouvrira normalement lors des lancements suivants.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<a id="-run-on-old-android-phones"></a>
|
### 💻 TUI Launcher (Recommandé pour les environnements sans interface / SSH)
|
||||||
|
|
||||||
|
Le TUI (Terminal UI) Launcher fournit une interface terminal complète pour la configuration et la gestion. Idéal pour les serveurs, Raspberry Pi et autres environnements sans interface graphique.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw-launcher-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Pour commencer :**
|
||||||
|
|
||||||
|
Utilisez les menus TUI pour : **1)** Configurer un Provider -> **2)** Configurer un Channel -> **3)** Démarrer le Gateway -> **4)** Chattez !
|
||||||
|
|
||||||
|
Pour la documentation détaillée du TUI, voir [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||||
|
|
||||||
### 📱 Android
|
### 📱 Android
|
||||||
|
|
||||||
Donnez une seconde vie à votre téléphone vieux de dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw.
|
Donnez une seconde vie à votre téléphone vieux de dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw.
|
||||||
|
|
@ -311,10 +312,10 @@ Aperçu :
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td><img src="../../assets/fui_main_page.jpg" width="200"></td>
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_web_page.jpg" width="200"></td>
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_log_page.jpg" width="200"></td>
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_setting_page.jpg" width="200"></td>
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
@ -338,7 +339,7 @@ termux-chroot ./picoclaw onboard # chroot fournit une arborescence Linux stand
|
||||||
|
|
||||||
Suivez ensuite la section Terminal Launcher ci-dessous pour terminer la configuration.
|
Suivez ensuite la section Terminal Launcher ci-dessous pour terminer la configuration.
|
||||||
|
|
||||||
<img src="../../assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||||
|
|
||||||
Pour les environnements minimaux où seul le binaire principal `picoclaw` est disponible (sans Launcher UI), vous pouvez tout configurer via la ligne de commande et un fichier de configuration JSON.
|
Pour les environnements minimaux où seul le binaire principal `picoclaw` est disponible (sans Launcher UI), vous pouvez tout configurer via la ligne de commande et un fichier de configuration JSON.
|
||||||
|
|
||||||
|
|
@ -354,7 +355,6 @@ Cela crée `~/.picoclaw/config.json` et le répertoire workspace.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 3,
|
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model_name": "gpt-5.4"
|
"model_name": "gpt-5.4"
|
||||||
|
|
@ -364,7 +364,7 @@ Cela crée `~/.picoclaw/config.json` et le répertoire workspace.
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_keys": ["sk-your-api-key"]
|
"api_key": "sk-your-api-key"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -446,7 +446,7 @@ PicoClaw supporte plus de 30 providers LLM via la configuration `model_list`. Ut
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Pour les détails complets de configuration des providers, voir [Providers & Models](../guides/providers.fr.md).
|
Pour les détails complets de configuration des providers, voir [Providers & Models](docs/fr/providers.md).
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -456,28 +456,28 @@ Parlez à votre PicoClaw via plus de 17 plateformes de messagerie :
|
||||||
|
|
||||||
| Channel | Configuration | Protocole | Docs |
|
| Channel | Configuration | Protocole | Docs |
|
||||||
|---------|---------------|-----------|------|
|
|---------|---------------|-----------|------|
|
||||||
| **Telegram** | Facile (token bot) | Long polling | [Guide](../channels/telegram/README.fr.md) |
|
| **Telegram** | Facile (token bot) | Long polling | [Guide](docs/channels/telegram/README.fr.md) |
|
||||||
| **Discord** | Facile (token bot + intents) | WebSocket | [Guide](../channels/discord/README.fr.md) |
|
| **Discord** | Facile (token bot + intents) | WebSocket | [Guide](docs/channels/discord/README.fr.md) |
|
||||||
| **WhatsApp** | Facile (scan QR ou URL bridge) | Natif / Bridge | [Guide](../guides/chat-apps.fr.md#whatsapp) |
|
| **WhatsApp** | Facile (scan QR ou URL bridge) | Natif / Bridge | [Guide](docs/fr/chat-apps.md#whatsapp) |
|
||||||
| **Weixin** | Facile (scan QR natif) | iLink API | [Guide](../guides/chat-apps.fr.md#weixin) |
|
| **Weixin** | Facile (scan QR natif) | iLink API | [Guide](docs/fr/chat-apps.md#weixin) |
|
||||||
| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guide](../channels/qq/README.fr.md) |
|
| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.fr.md) |
|
||||||
| **Slack** | Facile (token bot + app) | Socket Mode | [Guide](../channels/slack/README.fr.md) |
|
| **Slack** | Facile (token bot + app) | Socket Mode | [Guide](docs/channels/slack/README.fr.md) |
|
||||||
| **Matrix** | Moyen (homeserver + token) | Sync API | [Guide](../channels/matrix/README.fr.md) |
|
| **Matrix** | Moyen (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.fr.md) |
|
||||||
| **DingTalk** | Moyen (identifiants client) | Stream | [Guide](../channels/dingtalk/README.fr.md) |
|
| **DingTalk** | Moyen (identifiants client) | Stream | [Guide](docs/channels/dingtalk/README.fr.md) |
|
||||||
| **Feishu / Lark** | Moyen (App ID + Secret) | WebSocket/SDK | [Guide](../channels/feishu/README.fr.md) |
|
| **Feishu / Lark** | Moyen (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.fr.md) |
|
||||||
| **LINE** | Moyen (identifiants + webhook) | Webhook | [Guide](../channels/line/README.fr.md) |
|
| **LINE** | Moyen (identifiants + webhook) | Webhook | [Guide](docs/channels/line/README.fr.md) |
|
||||||
| **WeCom** | Facile (QR login ou manuel) | WebSocket | [Guide](../channels/wecom/README.fr.md) |
|
| **WeCom** | Facile (QR login ou manuel) | WebSocket | [Guide](docs/channels/wecom/README.md) |
|
||||||
| **IRC** | Moyen (serveur + pseudo) | Protocole IRC | [Guide](../guides/chat-apps.fr.md#irc) |
|
| **IRC** | Moyen (serveur + pseudo) | Protocole IRC | [Guide](docs/fr/chat-apps.md#irc) |
|
||||||
| **OneBot** | Moyen (URL WebSocket) | OneBot v11 | [Guide](../channels/onebot/README.fr.md) |
|
| **OneBot** | Moyen (URL WebSocket) | OneBot v11 | [Guide](docs/channels/onebot/README.fr.md) |
|
||||||
| **MaixCam** | Facile (activer) | Socket TCP | [Guide](../channels/maixcam/README.fr.md) |
|
| **MaixCam** | Facile (activer) | Socket TCP | [Guide](docs/channels/maixcam/README.fr.md) |
|
||||||
| **Pico** | Facile (activer) | Protocole natif | Intégré |
|
| **Pico** | Facile (activer) | Protocole natif | Intégré |
|
||||||
| **Pico Client** | Facile (URL WebSocket) | WebSocket | Intégré |
|
| **Pico Client** | Facile (URL WebSocket) | WebSocket | Intégré |
|
||||||
|
|
||||||
> Tous les channels basés sur webhook partagent un seul serveur HTTP Gateway (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP partagé.
|
> Tous les channels basés sur webhook partagent un seul serveur HTTP Gateway (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP partagé.
|
||||||
|
|
||||||
> La verbosité des logs est contrôlée par `gateway.log_level` (par défaut : `warn`). Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`. Peut aussi être défini via `PICOCLAW_LOG_LEVEL`. Voir [Configuration](../guides/configuration.fr.md#niveau-de-log-du-gateway) pour plus de détails.
|
> La verbosité des logs est contrôlée par `gateway.log_level` (par défaut : `warn`). Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`. Peut aussi être défini via `PICOCLAW_LOG_LEVEL`. Voir [Configuration](docs/fr/configuration.md#niveau-de-log-du-gateway) pour plus de détails.
|
||||||
|
|
||||||
Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](../guides/chat-apps.fr.md).
|
Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](docs/fr/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Outils
|
## 🔧 Outils
|
||||||
|
|
||||||
|
|
@ -488,7 +488,7 @@ PicoClaw peut effectuer des recherches sur le web pour fournir des informations
|
||||||
| Moteur de recherche | Clé API | Niveau gratuit | Lien |
|
| Moteur de recherche | Clé API | Niveau gratuit | Lien |
|
||||||
|--------------------|---------|----------------|------|
|
|--------------------|---------|----------------|------|
|
||||||
| DuckDuckGo | Non requise | Illimité | Fallback intégré |
|
| DuckDuckGo | Non requise | Illimité | Fallback intégré |
|
||||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Requise | 1500 requêtes/mois (allocation journalière) | IA, optimisé pour le chinois |
|
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Requise | 1000 requêtes/jour | IA, optimisé pour le chinois |
|
||||||
| [Tavily](https://tavily.com) | Requise | 1000 requêtes/mois | Optimisé pour les Agents IA |
|
| [Tavily](https://tavily.com) | Requise | 1000 requêtes/mois | Optimisé pour les Agents IA |
|
||||||
| [Brave Search](https://brave.com/search/api) | Requise | 2000 requêtes/mois | Rapide et privé |
|
| [Brave Search](https://brave.com/search/api) | Requise | 2000 requêtes/mois | Rapide et privé |
|
||||||
| [Perplexity](https://www.perplexity.ai) | Requise | Payant | Recherche propulsée par IA |
|
| [Perplexity](https://www.perplexity.ai) | Requise | Payant | Recherche propulsée par IA |
|
||||||
|
|
@ -497,7 +497,7 @@ PicoClaw peut effectuer des recherches sur le web pour fournir des informations
|
||||||
|
|
||||||
### ⚙️ Autres outils
|
### ⚙️ Autres outils
|
||||||
|
|
||||||
PicoClaw inclut des outils intégrés pour les opérations sur fichiers, l'exécution de code, la planification et plus encore. Voir [Configuration des outils](../reference/tools_configuration.fr.md) pour les détails.
|
PicoClaw inclut des outils intégrés pour les opérations sur fichiers, l'exécution de code, la planification et plus encore. Voir [Configuration des outils](docs/fr/tools_configuration.md) pour les détails.
|
||||||
|
|
||||||
## 🎯 Skills
|
## 🎯 Skills
|
||||||
|
|
||||||
|
|
@ -527,7 +527,7 @@ Ajoutez à votre `config.json` :
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Pour plus de détails, voir [Configuration des outils - Skills](../reference/tools_configuration.fr.md#skills-tool).
|
Pour plus de détails, voir [Configuration des outils - Skills](docs/fr/tools_configuration.md#skills-tool).
|
||||||
|
|
||||||
## 🔗 MCP (Model Context Protocol)
|
## 🔗 MCP (Model Context Protocol)
|
||||||
|
|
||||||
|
|
@ -550,9 +550,9 @@ PicoClaw supporte nativement [MCP](https://modelcontextprotocol.io/) — connect
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Pour la configuration MCP complète (transports stdio, SSE, HTTP, Tool Discovery), voir [Configuration des outils - MCP](../reference/tools_configuration.fr.md#mcp-tool).
|
Pour la configuration MCP complète (transports stdio, SSE, HTTP, Tool Discovery), voir [Configuration des outils - MCP](docs/fr/tools_configuration.md#mcp-tool).
|
||||||
|
|
||||||
## <img src="../../assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Rejoignez le réseau social des Agents
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Rejoignez le réseau social des Agents
|
||||||
|
|
||||||
Connectez PicoClaw au réseau social des Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intégrée.
|
Connectez PicoClaw au réseau social des Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intégrée.
|
||||||
|
|
||||||
|
|
@ -593,23 +593,23 @@ Pour des guides détaillés au-delà de ce README :
|
||||||
|
|
||||||
| Sujet | Description |
|
| Sujet | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| [Docker & Démarrage rapide](../guides/docker.fr.md) | Configuration Docker Compose, modes Launcher/Agent |
|
| [Docker & Démarrage rapide](docs/fr/docker.md) | Configuration Docker Compose, modes Launcher/Agent |
|
||||||
| [Applications de chat](../guides/chat-apps.fr.md) | Guides de configuration pour les 17+ channels |
|
| [Applications de chat](docs/fr/chat-apps.md) | Guides de configuration pour les 17+ channels |
|
||||||
| [Configuration](../guides/configuration.fr.md) | Variables d'environnement, structure du workspace, sandbox de sécurité |
|
| [Configuration](docs/fr/configuration.md) | Variables d'environnement, structure du workspace, sandbox de sécurité |
|
||||||
| [Providers & Modèles](../guides/providers.fr.md) | 30+ providers LLM, routage de modèles, configuration model_list |
|
| [Providers & Modèles](docs/fr/providers.md) | 30+ providers LLM, routage de modèles, configuration model_list |
|
||||||
| [Spawn & Tâches asynchrones](../guides/spawn-tasks.fr.md) | Tâches rapides, tâches longues avec spawn, orchestration de sous-agents asynchrones |
|
| [Spawn & Tâches asynchrones](docs/fr/spawn-tasks.md) | Tâches rapides, tâches longues avec spawn, orchestration de sous-agents asynchrones |
|
||||||
| [Hooks](../architecture/hooks/README.md) | Système de hooks événementiels : observateurs, intercepteurs, hooks d'approbation |
|
| [Hooks](docs/hooks/README.md) | Système de hooks événementiels : observateurs, intercepteurs, hooks d'approbation |
|
||||||
| [Steering](../architecture/steering.md) | Injecter des messages dans une boucle agent en cours d'exécution |
|
| [Steering](docs/steering.md) | Injecter des messages dans une boucle agent en cours d'exécution |
|
||||||
| [SubTurn](../architecture/subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie |
|
| [SubTurn](docs/subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie |
|
||||||
| [Dépannage](../operations/troubleshooting.fr.md) | Problèmes courants et solutions |
|
| [Dépannage](docs/fr/troubleshooting.md) | Problèmes courants et solutions |
|
||||||
| [Configuration des outils](../reference/tools_configuration.fr.md) | Activation/désactivation par outil, politiques d'exécution, MCP, Skills |
|
| [Configuration des outils](docs/fr/tools_configuration.md) | Activation/désactivation par outil, politiques d'exécution, MCP, Skills |
|
||||||
| [Compatibilité matérielle](../guides/hardware-compatibility.fr.md) | Cartes testées, exigences minimales |
|
| [Compatibilité matérielle](docs/fr/hardware-compatibility.md) | Cartes testées, exigences minimales |
|
||||||
|
|
||||||
## 🤝 Contribuer & Roadmap
|
## 🤝 Contribuer & Roadmap
|
||||||
|
|
||||||
Les PRs sont les bienvenues ! Le code source est intentionnellement petit et lisible.
|
Les PRs sont les bienvenues ! Le code source est intentionnellement petit et lisible.
|
||||||
|
|
||||||
Consultez notre [Roadmap communautaire](https://github.com/sipeed/picoclaw/issues/988) et [CONTRIBUTING.md](../../CONTRIBUTING.md) pour les directives.
|
Consultez notre [Roadmap communautaire](https://github.com/sipeed/picoclaw/issues/988) et [CONTRIBUTING.md](CONTRIBUTING.md) pour les directives.
|
||||||
|
|
||||||
Groupe de développeurs en construction, rejoignez-le après votre première PR fusionnée !
|
Groupe de développeurs en construction, rejoignez-le après votre première PR fusionnée !
|
||||||
|
|
||||||
|
|
@ -618,4 +618,8 @@ Groupes d'utilisateurs :
|
||||||
Discord : <https://discord.gg/V4sAZ9XWpN>
|
Discord : <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
WeChat :
|
WeChat :
|
||||||
<img src="../../assets/wechat.png" alt="WeChat group QR code" width="512">
|
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="../../assets/logo.webp" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: Asisten AI Super Ringan berbasis Go</h1>
|
<h1>PicoClaw: Asisten AI Super Ringan berbasis Go</h1>
|
||||||
|
|
||||||
|
|
@ -14,11 +14,11 @@
|
||||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||||
<br>
|
<br>
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
<a href="../../assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **Bahasa Indonesia** | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Malay](README.my.md) | [English](README.md) | **Bahasa Indonesia**
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -34,12 +34,12 @@
|
||||||
<tr align="center">
|
<tr align="center">
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/picoclaw_mem.gif" width="360" height="240">
|
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/licheervnano.png" width="400" height="240">
|
<img src="assets/licheervnano.png" width="400" height="240">
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -56,14 +56,6 @@
|
||||||
|
|
||||||
## 📢 Berita
|
## 📢 Berita
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Kini Anda dapat membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), sehingga lebih mudah mencoba PicoClaw di hardware RISC-V ringkas.
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
|
||||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
2026-03-31 📱 **Dukungan Android!** PicoClaw sekarang berjalan di Android! Unduh APK di [picoclaw.io](https://picoclaw.io/download)
|
2026-03-31 📱 **Dukungan Android!** PicoClaw sekarang berjalan di Android! Unduh APK di [picoclaw.io](https://picoclaw.io/download)
|
||||||
|
|
||||||
2026-03-25 🚀 **v0.2.4 Dirilis!** Perombakan arsitektur Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keamanan (.security.yml, penyaringan data sensitif), provider baru (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 perbaikan bug. PicoClaw telah mencapai **26K Stars**!
|
2026-03-25 🚀 **v0.2.4 Dirilis!** Perombakan arsitektur Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keamanan (.security.yml, penyaringan data sensitif), provider baru (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 perbaikan bug. PicoClaw telah mencapai **26K Stars**!
|
||||||
|
|
@ -79,7 +71,7 @@
|
||||||
|
|
||||||
2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas kini aktif.
|
2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas kini aktif.
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw menembus 12K Stars dalam satu minggu! Peran maintainer komunitas dan [Roadmap](../../ROADMAP.md) resmi diluncurkan.
|
2026-02-16 🎉 PicoClaw menembus 12K Stars dalam satu minggu! Peran maintainer komunitas dan [Roadmap](ROADMAP.md) resmi diluncurkan.
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw menembus 5000 Stars dalam 4 hari! Roadmap proyek dan grup pengembang sedang dalam proses.
|
2026-02-13 🎉 PicoClaw menembus 5000 Stars dalam 4 hari! Roadmap proyek dan grup pengembang sedang dalam proses.
|
||||||
|
|
||||||
|
|
@ -116,14 +108,14 @@ _*Build terbaru mungkin menggunakan 10-20MB karena penggabungan PR yang cepat. O
|
||||||
| **Waktu Boot**</br>(core 0,8GHz) | >500d | >30d | **<1d** |
|
| **Waktu Boot**</br>(core 0,8GHz) | >500d | >30d | **<1d** |
|
||||||
| **Biaya** | Mac Mini $599 | Kebanyakan board Linux ~$50 | **Board Linux mana pun**</br>**mulai $10** |
|
| **Biaya** | Mac Mini $599 | Kebanyakan board Linux ~$50 | **Board Linux mana pun**</br>**mulai $10** |
|
||||||
|
|
||||||
<img src="../../assets/compare.jpg" alt="PicoClaw" width="512">
|
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **[Daftar Kompatibilitas Hardware](../guides/hardware-compatibility.md)** — Lihat semua board yang telah diuji, dari RISC-V $5 hingga Raspberry Pi hingga ponsel Android. Board Anda belum terdaftar? Kirim PR!
|
> **[Daftar Kompatibilitas Hardware](docs/hardware-compatibility.md)** — Lihat semua board yang telah diuji, dari RISC-V $5 hingga Raspberry Pi hingga ponsel Android. Board Anda belum terdaftar? Kirim PR!
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 🦾 Demonstrasi
|
## 🦾 Demonstrasi
|
||||||
|
|
@ -137,9 +129,9 @@ _*Build terbaru mungkin menggunakan 10-20MB karena penggabungan PR yang cepat. O
|
||||||
<th><p align="center">Pencarian Web & Pembelajaran</p></th>
|
<th><p align="center">Pencarian Web & Pembelajaran</p></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">Develop · Deploy · Scale</td>
|
<td align="center">Develop · Deploy · Scale</td>
|
||||||
|
|
@ -172,27 +164,19 @@ Atau, unduh binary untuk platform Anda dari halaman [GitHub Releases](https://gi
|
||||||
|
|
||||||
### Build dari source (untuk pengembangan)
|
### Build dari source (untuk pengembangan)
|
||||||
|
|
||||||
Prasyarat:
|
|
||||||
|
|
||||||
- Go 1.25+
|
|
||||||
- Node.js 22+ dan pnpm 10.33.0+ untuk build Web UI / launcher
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Instal dependensi frontend
|
|
||||||
(cd web/frontend && pnpm install --frozen-lockfile)
|
|
||||||
|
|
||||||
# Build binary inti
|
# Build binary inti
|
||||||
make build
|
make build
|
||||||
|
|
||||||
# Build Web UI Launcher (diperlukan untuk mode WebUI)
|
# Build Web UI Launcher (diperlukan untuk mode WebUI)
|
||||||
make build-launcher
|
make build-launcher
|
||||||
|
|
||||||
# Build binary inti untuk semua platform yang dikelola Makefile
|
# Build untuk berbagai platform
|
||||||
make build-all
|
make build-all
|
||||||
|
|
||||||
# Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
# Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||||
|
|
@ -228,7 +212,7 @@ picoclaw-launcher
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Memulai:**
|
**Memulai:**
|
||||||
|
|
@ -282,7 +266,7 @@ macOS mungkin memblokir `picoclaw-launcher` saat pertama kali diluncurkan karena
|
||||||
**Langkah 1:** Klik dua kali `picoclaw-launcher`. Anda akan melihat peringatan keamanan:
|
**Langkah 1:** Klik dua kali `picoclaw-launcher`. Anda akan melihat peringatan keamanan:
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/macos-gatekeeper-warning.jpg" alt="Peringatan macOS Gatekeeper" width="400">
|
<img src="assets/macos-gatekeeper-warning.jpg" alt="Peringatan macOS Gatekeeper" width="400">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> *"picoclaw-launcher" Tidak Dapat Dibuka — Apple tidak dapat memverifikasi bahwa "picoclaw-launcher" bebas dari malware yang dapat membahayakan Mac Anda atau mengancam privasi Anda.*
|
> *"picoclaw-launcher" Tidak Dapat Dibuka — Apple tidak dapat memverifikasi bahwa "picoclaw-launcher" bebas dari malware yang dapat membahayakan Mac Anda atau mengancam privasi Anda.*
|
||||||
|
|
@ -290,13 +274,31 @@ macOS mungkin memblokir `picoclaw-launcher` saat pertama kali diluncurkan karena
|
||||||
**Langkah 2:** Buka **Pengaturan Sistem** → **Privasi & Keamanan** → gulir ke bawah ke bagian **Keamanan** → klik **Tetap Buka** → konfirmasi dengan mengklik **Tetap Buka** pada dialog.
|
**Langkah 2:** Buka **Pengaturan Sistem** → **Privasi & Keamanan** → gulir ke bawah ke bagian **Keamanan** → klik **Tetap Buka** → konfirmasi dengan mengklik **Tetap Buka** pada dialog.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/macos-gatekeeper-allow.jpg" alt="macOS Privasi & Keamanan — Tetap Buka" width="600">
|
<img src="assets/macos-gatekeeper-allow.jpg" alt="macOS Privasi & Keamanan — Tetap Buka" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Setelah langkah satu kali ini, `picoclaw-launcher` akan terbuka secara normal pada peluncuran berikutnya.
|
Setelah langkah satu kali ini, `picoclaw-launcher` akan terbuka secara normal pada peluncuran berikutnya.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### 💻 TUI Launcher (Direkomendasikan untuk Headless / SSH)
|
||||||
|
|
||||||
|
TUI (Terminal UI) Launcher menyediakan antarmuka terminal lengkap untuk konfigurasi dan manajemen. Ideal untuk server, Raspberry Pi, dan lingkungan headless lainnya.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw-launcher-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Memulai:**
|
||||||
|
|
||||||
|
Gunakan menu TUI untuk: **1)** Konfigurasi Provider -> **2)** Konfigurasi Channel -> **3)** Mulai Gateway -> **4)** Chat!
|
||||||
|
|
||||||
|
Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||||
|
|
||||||
### 📱 Android
|
### 📱 Android
|
||||||
|
|
||||||
Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw.
|
Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw.
|
||||||
|
|
@ -307,10 +309,10 @@ Pratinjau:
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td><img src="../../assets/fui_main_page.jpg" width="200"></td>
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_web_page.jpg" width="200"></td>
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_log_page.jpg" width="200"></td>
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_setting_page.jpg" width="200"></td>
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
@ -334,7 +336,7 @@ termux-chroot ./picoclaw onboard # chroot menyediakan tata letak filesystem Li
|
||||||
|
|
||||||
Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi.
|
Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi.
|
||||||
|
|
||||||
<img src="../../assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||||
|
|
||||||
Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON.
|
Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON.
|
||||||
|
|
||||||
|
|
@ -350,7 +352,6 @@ Ini membuat `~/.picoclaw/config.json` dan direktori workspace.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 3,
|
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model_name": "gpt-5.4"
|
"model_name": "gpt-5.4"
|
||||||
|
|
@ -360,7 +361,7 @@ Ini membuat `~/.picoclaw/config.json` dan direktori workspace.
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_keys": ["sk-your-api-key"]
|
"api_key": "sk-your-api-key"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -441,7 +442,7 @@ PicoClaw mendukung 30+ provider LLM melalui konfigurasi `model_list`. Gunakan fo
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](../guides/providers.md).
|
Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](docs/providers.md).
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -451,28 +452,28 @@ Bicara dengan PicoClaw Anda melalui 17+ platform pesan:
|
||||||
|
|
||||||
| Channel | Pengaturan | Protocol | Dokumentasi |
|
| Channel | Pengaturan | Protocol | Dokumentasi |
|
||||||
|---------|------------|----------|-------------|
|
|---------|------------|----------|-------------|
|
||||||
| **Telegram** | Mudah (bot token) | Long polling | [Panduan](../channels/telegram/README.md) |
|
| **Telegram** | Mudah (bot token) | Long polling | [Panduan](docs/channels/telegram/README.md) |
|
||||||
| **Discord** | Mudah (bot token + intents) | WebSocket | [Panduan](../channels/discord/README.md) |
|
| **Discord** | Mudah (bot token + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) |
|
||||||
| **WhatsApp** | Mudah (scan QR atau bridge URL) | Native / Bridge | [Panduan](../guides/chat-apps.md#whatsapp) |
|
| **WhatsApp** | Mudah (scan QR atau bridge URL) | Native / Bridge | [Panduan](docs/chat-apps.md#whatsapp) |
|
||||||
| **Weixin** | Mudah (scan QR native) | iLink API | [Panduan](../guides/chat-apps.md#weixin) |
|
| **Weixin** | Mudah (scan QR native) | iLink API | [Panduan](docs/chat-apps.md#weixin) |
|
||||||
| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](../channels/qq/README.md) |
|
| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) |
|
||||||
| **Slack** | Mudah (bot + app token) | Socket Mode | [Panduan](../channels/slack/README.md) |
|
| **Slack** | Mudah (bot + app token) | Socket Mode | [Panduan](docs/channels/slack/README.md) |
|
||||||
| **Matrix** | Sedang (homeserver + token) | Sync API | [Panduan](../channels/matrix/README.md) |
|
| **Matrix** | Sedang (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) |
|
||||||
| **DingTalk** | Sedang (client credentials) | Stream | [Panduan](../channels/dingtalk/README.md) |
|
| **DingTalk** | Sedang (client credentials) | Stream | [Panduan](docs/channels/dingtalk/README.md) |
|
||||||
| **Feishu / Lark** | Sedang (App ID + Secret) | WebSocket/SDK | [Panduan](../channels/feishu/README.md) |
|
| **Feishu / Lark** | Sedang (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) |
|
||||||
| **LINE** | Sedang (credentials + webhook) | Webhook | [Panduan](../channels/line/README.md) |
|
| **LINE** | Sedang (credentials + webhook) | Webhook | [Panduan](docs/channels/line/README.md) |
|
||||||
| **WeCom** | Mudah (login QR atau manual) | WebSocket | [Panduan](../channels/wecom/README.md) |
|
| **WeCom** | Mudah (login QR atau manual) | WebSocket | [Panduan](docs/channels/wecom/README.md) |
|
||||||
| **IRC** | Sedang (server + nick) | IRC protocol | [Panduan](../guides/chat-apps.md#irc) |
|
| **IRC** | Sedang (server + nick) | IRC protocol | [Panduan](docs/chat-apps.md#irc) |
|
||||||
| **OneBot** | Sedang (WebSocket URL) | OneBot v11 | [Panduan](../channels/onebot/README.md) |
|
| **OneBot** | Sedang (WebSocket URL) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) |
|
||||||
| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](../channels/maixcam/README.md) |
|
| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) |
|
||||||
| **Pico** | Mudah (aktifkan) | Native protocol | Bawaan |
|
| **Pico** | Mudah (aktifkan) | Native protocol | Bawaan |
|
||||||
| **Pico Client** | Mudah (WebSocket URL) | WebSocket | Bawaan |
|
| **Pico Client** | Mudah (WebSocket URL) | WebSocket | Bawaan |
|
||||||
|
|
||||||
> Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama.
|
> Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama.
|
||||||
|
|
||||||
> Verbositas log dikontrol oleh `gateway.log_level` (default: `warn`). Nilai yang didukung: `debug`, `info`, `warn`, `error`, `fatal`. Juga dapat diatur melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](../guides/configuration.md#gateway-log-level) untuk detail.
|
> Verbositas log dikontrol oleh `gateway.log_level` (default: `warn`). Nilai yang didukung: `debug`, `info`, `warn`, `error`, `fatal`. Juga dapat diatur melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk detail.
|
||||||
|
|
||||||
Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](../guides/chat-apps.md).
|
Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Tools
|
## 🔧 Tools
|
||||||
|
|
||||||
|
|
@ -483,7 +484,7 @@ PicoClaw dapat mencari web untuk memberikan informasi terkini. Konfigurasi di `t
|
||||||
| Mesin Pencari | API Key | Tier Gratis | Tautan |
|
| Mesin Pencari | API Key | Tier Gratis | Tautan |
|
||||||
|--------------|---------|-------------|--------|
|
|--------------|---------|-------------|--------|
|
||||||
| DuckDuckGo | Tidak perlu | Tidak terbatas | Fallback bawaan |
|
| DuckDuckGo | Tidak perlu | Tidak terbatas | Fallback bawaan |
|
||||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1500 kueri/bulan (alokasi harian) | Bertenaga AI, dioptimalkan untuk bahasa Mandarin |
|
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 kueri/hari | Bertenaga AI, dioptimalkan untuk bahasa Mandarin |
|
||||||
| [Tavily](https://tavily.com) | Diperlukan | 1000 kueri/bulan | Dioptimalkan untuk AI Agent |
|
| [Tavily](https://tavily.com) | Diperlukan | 1000 kueri/bulan | Dioptimalkan untuk AI Agent |
|
||||||
| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 kueri/bulan | Cepat dan privat |
|
| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 kueri/bulan | Cepat dan privat |
|
||||||
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Pencarian bertenaga AI |
|
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Pencarian bertenaga AI |
|
||||||
|
|
@ -492,7 +493,7 @@ PicoClaw dapat mencari web untuk memberikan informasi terkini. Konfigurasi di `t
|
||||||
|
|
||||||
### ⚙️ Tools Lainnya
|
### ⚙️ Tools Lainnya
|
||||||
|
|
||||||
PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](../reference/tools_configuration.md) untuk detail.
|
PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](docs/tools_configuration.md) untuk detail.
|
||||||
|
|
||||||
## 🎯 Skills
|
## 🎯 Skills
|
||||||
|
|
||||||
|
|
@ -522,7 +523,7 @@ Tambahkan ke `config.json` Anda:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](../reference/tools_configuration.md#skills-tool).
|
Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](docs/tools_configuration.md#skills-tool).
|
||||||
|
|
||||||
## 🔗 MCP (Model Context Protocol)
|
## 🔗 MCP (Model Context Protocol)
|
||||||
|
|
||||||
|
|
@ -545,9 +546,9 @@ PicoClaw mendukung [MCP](https://modelcontextprotocol.io/) secara native — hub
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Untuk konfigurasi MCP lengkap (transport stdio, SSE, HTTP, Tool Discovery), lihat [Konfigurasi Tools - MCP](../reference/tools_configuration.md#mcp-tool).
|
Untuk konfigurasi MCP lengkap (transport stdio, SSE, HTTP, Tool Discovery), lihat [Konfigurasi Tools - MCP](docs/tools_configuration.md#mcp-tool).
|
||||||
|
|
||||||
## <img src="../../assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Bergabung dengan Jaringan Sosial Agent
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Bergabung dengan Jaringan Sosial Agent
|
||||||
|
|
||||||
Hubungkan PicoClaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi mana pun.
|
Hubungkan PicoClaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi mana pun.
|
||||||
|
|
||||||
|
|
@ -588,23 +589,23 @@ Untuk panduan lengkap di luar README ini:
|
||||||
|
|
||||||
| Topik | Deskripsi |
|
| Topik | Deskripsi |
|
||||||
|-------|-----------|
|
|-------|-----------|
|
||||||
| [Docker & Panduan Cepat](../guides/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent |
|
| [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent |
|
||||||
| [Aplikasi Chat](../guides/chat-apps.md) | Semua 17+ panduan pengaturan channel |
|
| [Aplikasi Chat](docs/chat-apps.md) | Semua 17+ panduan pengaturan channel |
|
||||||
| [Konfigurasi](../guides/configuration.md) | Variabel environment, tata letak workspace, sandbox keamanan |
|
| [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sandbox keamanan |
|
||||||
| [Providers & Models](../guides/providers.md) | 30+ provider LLM, routing model, konfigurasi model_list |
|
| [Providers & Models](docs/providers.md) | 30+ provider LLM, routing model, konfigurasi model_list |
|
||||||
| [Spawn & Tugas Async](../guides/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async |
|
| [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async |
|
||||||
| [Hooks](../architecture/hooks/README.md) | Sistem hook berbasis event: observer, interceptor, approval hook |
|
| [Hooks](docs/hooks/README.md) | Sistem hook berbasis event: observer, interceptor, approval hook |
|
||||||
| [Steering](../architecture/steering.md) | Menyuntikkan pesan ke dalam loop agent yang sedang berjalan |
|
| [Steering](docs/steering.md) | Menyuntikkan pesan ke dalam loop agent yang sedang berjalan |
|
||||||
| [SubTurn](../architecture/subturn.md) | Koordinasi subagent, kontrol konkurensi, siklus hidup |
|
| [SubTurn](docs/subturn.md) | Koordinasi subagent, kontrol konkurensi, siklus hidup |
|
||||||
| [Pemecahan Masalah](../operations/troubleshooting.md) | Masalah umum dan solusinya |
|
| [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya |
|
||||||
| [Konfigurasi Tools](../reference/tools_configuration.md) | Aktifkan/nonaktifkan per-tool, kebijakan exec, MCP, Skills |
|
| [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan per-tool, kebijakan exec, MCP, Skills |
|
||||||
| [Kompatibilitas Hardware](../guides/hardware-compatibility.md) | Board yang telah diuji, persyaratan minimum |
|
| [Kompatibilitas Hardware](docs/hardware-compatibility.md) | Board yang telah diuji, persyaratan minimum |
|
||||||
|
|
||||||
## 🤝 Kontribusi & Roadmap
|
## 🤝 Kontribusi & Roadmap
|
||||||
|
|
||||||
PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca.
|
PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca.
|
||||||
|
|
||||||
Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](../../CONTRIBUTING.md) untuk panduan.
|
Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](CONTRIBUTING.md) untuk panduan.
|
||||||
|
|
||||||
Grup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge!
|
Grup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge!
|
||||||
|
|
||||||
|
|
@ -613,4 +614,5 @@ Grup Pengguna:
|
||||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
WeChat:
|
WeChat:
|
||||||
<img src="../../assets/wechat.png" alt="Kode QR grup WeChat" width="512">
|
<img src="assets/wechat.png" alt="Kode QR grup WeChat" width="512">
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="../../assets/logo.webp" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: Assistente IA Ultra-Efficiente in Go</h1>
|
<h1>PicoClaw: Assistente IA Ultra-Efficiente in Go</h1>
|
||||||
|
|
||||||
|
|
@ -14,11 +14,11 @@
|
||||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||||
<br>
|
<br>
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
<a href="../../assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -34,12 +34,12 @@
|
||||||
<tr align="center">
|
<tr align="center">
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/picoclaw_mem.gif" width="360" height="240">
|
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/licheervnano.png" width="400" height="240">
|
<img src="assets/licheervnano.png" width="400" height="240">
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -56,14 +56,6 @@
|
||||||
|
|
||||||
## 📢 Novità
|
## 📢 Novità
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw disponibile su AliExpress!** Ora puoi acquistare LicheeRV-Claw su [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), rendendo più semplice provare PicoClaw su hardware RISC-V compatto.
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
|
||||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
2026-03-31 📱 **Supporto Android!** PicoClaw ora funziona su Android! Scarica l'APK su [picoclaw.io](https://picoclaw.io/download)
|
2026-03-31 📱 **Supporto Android!** PicoClaw ora funziona su Android! Scarica l'APK su [picoclaw.io](https://picoclaw.io/download)
|
||||||
|
|
||||||
2026-03-25 🚀 **v0.2.4 rilasciata!** Revisione dell'architettura Agent (SubTurn, Hooks, Steering, EventBus), integrazione WeChat/WeCom, rafforzamento della sicurezza (.security.yml, filtraggio dati sensibili), nuovi provider (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correzioni di bug. PicoClaw raggiunge **26K Stars**!
|
2026-03-25 🚀 **v0.2.4 rilasciata!** Revisione dell'architettura Agent (SubTurn, Hooks, Steering, EventBus), integrazione WeChat/WeCom, rafforzamento della sicurezza (.security.yml, filtraggio dati sensibili), nuovi provider (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correzioni di bug. PicoClaw raggiunge **26K Stars**!
|
||||||
|
|
@ -79,7 +71,7 @@
|
||||||
|
|
||||||
2026-02-26 🎉 PicoClaw raggiunge **20K stelle** in soli 17 giorni! Orchestrazione automatica dei canali e interfacce di capacità sono attive.
|
2026-02-26 🎉 PicoClaw raggiunge **20K stelle** in soli 17 giorni! Orchestrazione automatica dei canali e interfacce di capacità sono attive.
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw supera 12K stelle in una settimana! Ruoli di maintainer della community e [Roadmap](../../ROADMAP.md) pubblicati ufficialmente.
|
2026-02-16 🎉 PicoClaw supera 12K stelle in una settimana! Ruoli di maintainer della community e [Roadmap](ROADMAP.md) pubblicati ufficialmente.
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw supera 5000 stelle in 4 giorni! Roadmap del progetto e gruppi sviluppatori in fase di avvio.
|
2026-02-13 🎉 PicoClaw supera 5000 stelle in 4 giorni! Roadmap del progetto e gruppi sviluppatori in fase di avvio.
|
||||||
|
|
||||||
|
|
@ -116,14 +108,14 @@ _*Le build recenti potrebbero usare 10-20MB a causa delle fusioni rapide di PR.
|
||||||
| **Avvio**</br>(core 0,8 GHz) | >500s | >30s | **<1s** |
|
| **Avvio**</br>(core 0,8 GHz) | >500s | >30s | **<1s** |
|
||||||
| **Costo** | Mac Mini $599 | La maggior parte degli SBC Linux ~$50 | **Qualsiasi scheda Linux**</br>**a partire da $10** |
|
| **Costo** | Mac Mini $599 | La maggior parte degli SBC Linux ~$50 | **Qualsiasi scheda Linux**</br>**a partire da $10** |
|
||||||
|
|
||||||
<img src="../../assets/compare.jpg" alt="PicoClaw" width="512">
|
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **[Lista di Compatibilità Hardware](../guides/hardware-compatibility.md)** — Vedi tutte le schede testate, dai $5 RISC-V al Raspberry Pi ai telefoni Android. La tua scheda non è elencata? Invia una PR!
|
> **[Lista di Compatibilità Hardware](docs/hardware-compatibility.md)** — Vedi tutte le schede testate, dai $5 RISC-V al Raspberry Pi ai telefoni Android. La tua scheda non è elencata? Invia una PR!
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 🦾 Dimostrazione
|
## 🦾 Dimostrazione
|
||||||
|
|
@ -137,9 +129,9 @@ _*Le build recenti potrebbero usare 10-20MB a causa delle fusioni rapide di PR.
|
||||||
<th><p align="center">Ricerca Web & Apprendimento</p></th>
|
<th><p align="center">Ricerca Web & Apprendimento</p></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">Sviluppa · Distribuisci · Scala</td>
|
<td align="center">Sviluppa · Distribuisci · Scala</td>
|
||||||
|
|
@ -172,27 +164,19 @@ In alternativa, scarica il binario per la tua piattaforma dalla pagina delle [Gi
|
||||||
|
|
||||||
### Compila dai sorgenti (per lo sviluppo)
|
### Compila dai sorgenti (per lo sviluppo)
|
||||||
|
|
||||||
Prerequisiti:
|
|
||||||
|
|
||||||
- Go 1.25+
|
|
||||||
- Node.js 22+ e pnpm 10.33.0+ per le build Web UI / launcher
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Installa le dipendenze frontend
|
|
||||||
(cd web/frontend && pnpm install --frozen-lockfile)
|
|
||||||
|
|
||||||
# Compila il binario core
|
# Compila il binario core
|
||||||
make build
|
make build
|
||||||
|
|
||||||
# Compila il Web UI Launcher (necessario per la modalità WebUI)
|
# Compila il Web UI Launcher (necessario per la modalità WebUI)
|
||||||
make build-launcher
|
make build-launcher
|
||||||
|
|
||||||
# Compila i binari core per tutte le piattaforme gestite dal Makefile
|
# Compila per più piattaforme
|
||||||
make build-all
|
make build-all
|
||||||
|
|
||||||
# Compila per Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
# Compila per Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||||
|
|
@ -228,7 +212,7 @@ picoclaw-launcher
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Per iniziare:**
|
**Per iniziare:**
|
||||||
|
|
@ -282,7 +266,7 @@ macOS potrebbe bloccare `picoclaw-launcher` al primo avvio perché è stato scar
|
||||||
**Passo 1:** Fai doppio clic su `picoclaw-launcher`. Verrà visualizzato un avviso di sicurezza:
|
**Passo 1:** Fai doppio clic su `picoclaw-launcher`. Verrà visualizzato un avviso di sicurezza:
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/macos-gatekeeper-warning.jpg" alt="Avviso macOS Gatekeeper" width="400">
|
<img src="assets/macos-gatekeeper-warning.jpg" alt="Avviso macOS Gatekeeper" width="400">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> *"picoclaw-launcher" Non Aperto — Apple non è riuscita a verificare che "picoclaw-launcher" sia privo di malware che potrebbe danneggiare il Mac o compromettere la privacy.*
|
> *"picoclaw-launcher" Non Aperto — Apple non è riuscita a verificare che "picoclaw-launcher" sia privo di malware che potrebbe danneggiare il Mac o compromettere la privacy.*
|
||||||
|
|
@ -290,13 +274,31 @@ macOS potrebbe bloccare `picoclaw-launcher` al primo avvio perché è stato scar
|
||||||
**Passo 2:** Apri **Impostazioni di Sistema** → **Privacy e sicurezza** → scorri fino alla sezione **Sicurezza** → clicca su **Apri comunque** → conferma cliccando su **Apri comunque** nella finestra di dialogo.
|
**Passo 2:** Apri **Impostazioni di Sistema** → **Privacy e sicurezza** → scorri fino alla sezione **Sicurezza** → clicca su **Apri comunque** → conferma cliccando su **Apri comunque** nella finestra di dialogo.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/macos-gatekeeper-allow.jpg" alt="macOS Privacy e sicurezza — Apri comunque" width="600">
|
<img src="assets/macos-gatekeeper-allow.jpg" alt="macOS Privacy e sicurezza — Apri comunque" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Dopo questo passaggio una tantum, `picoclaw-launcher` si aprirà normalmente ai lanci successivi.
|
Dopo questo passaggio una tantum, `picoclaw-launcher` si aprirà normalmente ai lanci successivi.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### 💻 TUI Launcher (Consigliato per Headless / SSH)
|
||||||
|
|
||||||
|
Il TUI (Terminal UI) Launcher fornisce un'interfaccia terminale completa per la configurazione e la gestione. Ideale per server, Raspberry Pi e altri ambienti headless.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw-launcher-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Per iniziare:**
|
||||||
|
|
||||||
|
Usa i menu TUI per: **1)** Configurare un Provider -> **2)** Configurare un Channel -> **3)** Avviare il Gateway -> **4)** Chattare!
|
||||||
|
|
||||||
|
Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||||
|
|
||||||
### 📱 Android
|
### 📱 Android
|
||||||
|
|
||||||
Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw.
|
Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw.
|
||||||
|
|
@ -307,10 +309,10 @@ Anteprima:
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td><img src="../../assets/fui_main_page.jpg" width="200"></td>
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_web_page.jpg" width="200"></td>
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_log_page.jpg" width="200"></td>
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_setting_page.jpg" width="200"></td>
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
@ -334,7 +336,7 @@ termux-chroot ./picoclaw onboard # chroot fornisce un layout standard del file
|
||||||
|
|
||||||
Poi segui la sezione Terminal Launcher qui sotto per completare la configurazione.
|
Poi segui la sezione Terminal Launcher qui sotto per completare la configurazione.
|
||||||
|
|
||||||
<img src="../../assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||||
|
|
||||||
Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON.
|
Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON.
|
||||||
|
|
||||||
|
|
@ -350,7 +352,6 @@ Questo crea `~/.picoclaw/config.json` e la directory workspace.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 3,
|
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model_name": "gpt-5.4"
|
"model_name": "gpt-5.4"
|
||||||
|
|
@ -360,7 +361,7 @@ Questo crea `~/.picoclaw/config.json` e la directory workspace.
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_keys": ["sk-your-api-key"]
|
"api_key": "sk-your-api-key"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -441,7 +442,7 @@ PicoClaw supporta 30+ provider LLM tramite la configurazione `model_list`. Usa i
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](../guides/providers.md).
|
Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](docs/providers.md).
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -451,28 +452,28 @@ Parla con il tuo PicoClaw attraverso 17+ piattaforme di messaggistica:
|
||||||
|
|
||||||
| Channel | Configurazione | Protocollo | Docs |
|
| Channel | Configurazione | Protocollo | Docs |
|
||||||
|---------|----------------|------------|------|
|
|---------|----------------|------------|------|
|
||||||
| **Telegram** | Facile (bot token) | Long polling | [Guida](../channels/telegram/README.md) |
|
| **Telegram** | Facile (bot token) | Long polling | [Guida](docs/channels/telegram/README.md) |
|
||||||
| **Discord** | Facile (bot token + intents) | WebSocket | [Guida](../channels/discord/README.md) |
|
| **Discord** | Facile (bot token + intents) | WebSocket | [Guida](docs/channels/discord/README.md) |
|
||||||
| **WhatsApp** | Facile (QR scan o bridge URL) | Nativo / Bridge | [Guida](../guides/chat-apps.md#whatsapp) |
|
| **WhatsApp** | Facile (QR scan o bridge URL) | Nativo / Bridge | [Guida](docs/chat-apps.md#whatsapp) |
|
||||||
| **Weixin** | Facile (scan QR nativo) | iLink API | [Guida](../guides/chat-apps.md#weixin) |
|
| **Weixin** | Facile (scan QR nativo) | iLink API | [Guida](docs/chat-apps.md#weixin) |
|
||||||
| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guida](../channels/qq/README.md) |
|
| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guida](docs/channels/qq/README.md) |
|
||||||
| **Slack** | Facile (bot + app token) | Socket Mode | [Guida](../channels/slack/README.md) |
|
| **Slack** | Facile (bot + app token) | Socket Mode | [Guida](docs/channels/slack/README.md) |
|
||||||
| **Matrix** | Medio (homeserver + token) | Sync API | [Guida](../channels/matrix/README.md) |
|
| **Matrix** | Medio (homeserver + token) | Sync API | [Guida](docs/channels/matrix/README.md) |
|
||||||
| **DingTalk** | Medio (credenziali client) | Stream | [Guida](../channels/dingtalk/README.md) |
|
| **DingTalk** | Medio (credenziali client) | Stream | [Guida](docs/channels/dingtalk/README.md) |
|
||||||
| **Feishu / Lark** | Medio (App ID + Secret) | WebSocket/SDK | [Guida](../channels/feishu/README.md) |
|
| **Feishu / Lark** | Medio (App ID + Secret) | WebSocket/SDK | [Guida](docs/channels/feishu/README.md) |
|
||||||
| **LINE** | Medio (credenziali + webhook) | Webhook | [Guida](../channels/line/README.md) |
|
| **LINE** | Medio (credenziali + webhook) | Webhook | [Guida](docs/channels/line/README.md) |
|
||||||
| **WeCom** | Facile (login QR o manuale) | WebSocket | [Guida](../channels/wecom/README.md) |
|
| **WeCom** | Facile (login QR o manuale) | WebSocket | [Guida](docs/channels/wecom/README.md) |
|
||||||
| **IRC** | Medio (server + nick) | Protocollo IRC | [Guida](../guides/chat-apps.md#irc) |
|
| **IRC** | Medio (server + nick) | Protocollo IRC | [Guida](docs/chat-apps.md#irc) |
|
||||||
| **OneBot** | Medio (WebSocket URL) | OneBot v11 | [Guida](../channels/onebot/README.md) |
|
| **OneBot** | Medio (WebSocket URL) | OneBot v11 | [Guida](docs/channels/onebot/README.md) |
|
||||||
| **MaixCam** | Facile (abilita) | TCP socket | [Guida](../channels/maixcam/README.md) |
|
| **MaixCam** | Facile (abilita) | TCP socket | [Guida](docs/channels/maixcam/README.md) |
|
||||||
| **Pico** | Facile (abilita) | Protocollo nativo | Integrato |
|
| **Pico** | Facile (abilita) | Protocollo nativo | Integrato |
|
||||||
| **Pico Client** | Facile (WebSocket URL) | WebSocket | Integrato |
|
| **Pico Client** | Facile (WebSocket URL) | WebSocket | Integrato |
|
||||||
|
|
||||||
> Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso.
|
> Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso.
|
||||||
|
|
||||||
> La verbosità dei log è controllata da `gateway.log_level` (default: `warn`). Valori supportati: `debug`, `info`, `warn`, `error`, `fatal`. Può essere impostato anche tramite `PICOCLAW_LOG_LEVEL`. Vedi [Configurazione](../guides/configuration.md#gateway-log-level) per i dettagli.
|
> La verbosità dei log è controllata da `gateway.log_level` (default: `warn`). Valori supportati: `debug`, `info`, `warn`, `error`, `fatal`. Può essere impostato anche tramite `PICOCLAW_LOG_LEVEL`. Vedi [Configurazione](docs/configuration.md#gateway-log-level) per i dettagli.
|
||||||
|
|
||||||
Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](../guides/chat-apps.md).
|
Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Strumenti
|
## 🔧 Strumenti
|
||||||
|
|
||||||
|
|
@ -483,7 +484,7 @@ PicoClaw può cercare sul web per fornire informazioni aggiornate. Configura in
|
||||||
| Motore di Ricerca | API Key | Piano Gratuito | Link |
|
| Motore di Ricerca | API Key | Piano Gratuito | Link |
|
||||||
|-------------------|---------|----------------|------|
|
|-------------------|---------|----------------|------|
|
||||||
| DuckDuckGo | Non necessaria | Illimitato | Fallback integrato |
|
| DuckDuckGo | Non necessaria | Illimitato | Fallback integrato |
|
||||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Richiesta | 1500 query/mese (allocazione giornaliera) | IA, ottimizzato per il cinese |
|
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Richiesta | 1000 query/giorno | IA, ottimizzato per il cinese |
|
||||||
| [Tavily](https://tavily.com) | Richiesta | 1000 query/mese | Ottimizzato per AI Agent |
|
| [Tavily](https://tavily.com) | Richiesta | 1000 query/mese | Ottimizzato per AI Agent |
|
||||||
| [Brave Search](https://brave.com/search/api) | Richiesta | 2000 query/mese | Veloce e privato |
|
| [Brave Search](https://brave.com/search/api) | Richiesta | 2000 query/mese | Veloce e privato |
|
||||||
| [Perplexity](https://www.perplexity.ai) | Richiesta | A pagamento | Ricerca potenziata dall'IA |
|
| [Perplexity](https://www.perplexity.ai) | Richiesta | A pagamento | Ricerca potenziata dall'IA |
|
||||||
|
|
@ -492,7 +493,7 @@ PicoClaw può cercare sul web per fornire informazioni aggiornate. Configura in
|
||||||
|
|
||||||
### ⚙️ Altri Strumenti
|
### ⚙️ Altri Strumenti
|
||||||
|
|
||||||
PicoClaw include strumenti integrati per operazioni su file, esecuzione di codice, pianificazione e altro. Vedi [Configurazione degli Strumenti](../reference/tools_configuration.md) per i dettagli.
|
PicoClaw include strumenti integrati per operazioni su file, esecuzione di codice, pianificazione e altro. Vedi [Configurazione degli Strumenti](docs/tools_configuration.md) per i dettagli.
|
||||||
|
|
||||||
## 🎯 Skill
|
## 🎯 Skill
|
||||||
|
|
||||||
|
|
@ -522,7 +523,7 @@ Aggiungi al tuo `config.json`:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](../reference/tools_configuration.md#skills-tool).
|
Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](docs/tools_configuration.md#skills-tool).
|
||||||
|
|
||||||
## 🔗 MCP (Model Context Protocol)
|
## 🔗 MCP (Model Context Protocol)
|
||||||
|
|
||||||
|
|
@ -545,22 +546,9 @@ PicoClaw supporta nativamente [MCP](https://modelcontextprotocol.io/) — connet
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Puoi gestire i casi MCP più comuni direttamente dalla CLI senza modificare a mano il JSON:
|
Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](docs/tools_configuration.md#mcp-tool).
|
||||||
|
|
||||||
```bash
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Unisciti al Social Network degli Agent
|
||||||
picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp
|
|
||||||
picoclaw mcp list
|
|
||||||
picoclaw mcp test filesystem
|
|
||||||
```
|
|
||||||
|
|
||||||
`picoclaw mcp` agisce come configuration manager: aggiorna `config.json` sotto `tools.mcp.servers`, ma non mantiene in esecuzione il processo del server.
|
|
||||||
|
|
||||||
Usa `picoclaw mcp edit` quando ti servono campi avanzati che non sono coperti da `picoclaw mcp add`.
|
|
||||||
Per esempio, `picoclaw mcp add` supporta `--deferred` e `--env-file`, mentre `picoclaw mcp edit` resta utile per modifiche JSON dirette e opzioni MCP meno comuni.
|
|
||||||
|
|
||||||
Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](../reference/tools_configuration.md#mcp-tool). Per la reference della CLI, vedi [MCP Server CLI](../reference/mcp-cli.md).
|
|
||||||
|
|
||||||
## <img src="../../assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Unisciti al Social Network degli Agent
|
|
||||||
|
|
||||||
Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singolo messaggio tramite CLI o qualsiasi app di chat integrata.
|
Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singolo messaggio tramite CLI o qualsiasi app di chat integrata.
|
||||||
|
|
||||||
|
|
@ -578,11 +566,6 @@ Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singol
|
||||||
| `picoclaw status` | Mostra lo stato |
|
| `picoclaw status` | Mostra lo stato |
|
||||||
| `picoclaw version` | Mostra le info sulla versione |
|
| `picoclaw version` | Mostra le info sulla versione |
|
||||||
| `picoclaw model` | Visualizza o cambia il modello predefinito |
|
| `picoclaw model` | Visualizza o cambia il modello predefinito |
|
||||||
| `picoclaw mcp list` | Elenca i server MCP configurati |
|
|
||||||
| `picoclaw mcp add ...` | Aggiunge o aggiorna un server MCP |
|
|
||||||
| `picoclaw mcp test` | Verifica la raggiungibilità di un server MCP |
|
|
||||||
| `picoclaw mcp edit` | Apre la config per modifiche MCP avanzate |
|
|
||||||
| `picoclaw mcp remove` | Rimuove un server MCP dalla config |
|
|
||||||
| `picoclaw cron list` | Elenca tutti i job pianificati |
|
| `picoclaw cron list` | Elenca tutti i job pianificati |
|
||||||
| `picoclaw cron add ...` | Aggiunge un job pianificato |
|
| `picoclaw cron add ...` | Aggiunge un job pianificato |
|
||||||
| `picoclaw cron disable` | Disabilita un job pianificato |
|
| `picoclaw cron disable` | Disabilita un job pianificato |
|
||||||
|
|
@ -606,24 +589,23 @@ Per guide dettagliate oltre questo README:
|
||||||
|
|
||||||
| Argomento | Descrizione |
|
| Argomento | Descrizione |
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| [Docker & Avvio Rapido](../guides/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent |
|
| [Docker & Avvio Rapido](docs/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent |
|
||||||
| [App di Chat](../guides/chat-apps.md) | Tutte le guide di configurazione per 17+ channel |
|
| [App di Chat](docs/chat-apps.md) | Tutte le guide di configurazione per 17+ channel |
|
||||||
| [Configurazione](../guides/configuration.md) | Variabili d'ambiente, struttura del workspace, sandbox di sicurezza |
|
| [Configurazione](docs/configuration.md) | Variabili d'ambiente, struttura del workspace, sandbox di sicurezza |
|
||||||
| [MCP Server CLI](../reference/mcp-cli.md) | Aggiunta, elenco, test, modifica e rimozione dei server MCP da CLI |
|
| [Provider & Modelli](docs/providers.md) | 30+ provider LLM, routing dei modelli, configurazione model_list |
|
||||||
| [Provider & Modelli](../guides/providers.md) | 30+ provider LLM, routing dei modelli, configurazione model_list |
|
| [Spawn & Task Asincroni](docs/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent |
|
||||||
| [Spawn & Task Asincroni](../guides/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent |
|
| [Hooks](docs/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook |
|
||||||
| [Hooks](../architecture/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook |
|
| [Steering](docs/steering.md) | Iniettare messaggi in un loop agent in esecuzione |
|
||||||
| [Steering](../architecture/steering.md) | Iniettare messaggi in un loop agent in esecuzione |
|
| [SubTurn](docs/subturn.md) | Coordinamento subagent, controllo concorrenza, ciclo di vita |
|
||||||
| [SubTurn](../architecture/subturn.md) | Coordinamento subagent, controllo concorrenza, ciclo di vita |
|
| [Risoluzione Problemi](docs/troubleshooting.md) | Problemi comuni e soluzioni |
|
||||||
| [Risoluzione Problemi](../operations/troubleshooting.md) | Problemi comuni e soluzioni |
|
| [Configurazione degli Strumenti](docs/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec, MCP, Skill |
|
||||||
| [Configurazione degli Strumenti](../reference/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec, MCP, Skill |
|
| [Compatibilità Hardware](docs/hardware-compatibility.md) | Schede testate, requisiti minimi |
|
||||||
| [Compatibilità Hardware](../guides/hardware-compatibility.md) | Schede testate, requisiti minimi |
|
|
||||||
|
|
||||||
## 🤝 Contribuisci & Roadmap
|
## 🤝 Contribuisci & Roadmap
|
||||||
|
|
||||||
Le PR sono benvenute! Il codice è volutamente piccolo e leggibile.
|
Le PR sono benvenute! Il codice è volutamente piccolo e leggibile.
|
||||||
|
|
||||||
Consulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](../../CONTRIBUTING.md) per le linee guida.
|
Consulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](CONTRIBUTING.md) per le linee guida.
|
||||||
|
|
||||||
Gruppo sviluppatori in costruzione, unisciti dopo la tua prima PR accettata!
|
Gruppo sviluppatori in costruzione, unisciti dopo la tua prima PR accettata!
|
||||||
|
|
||||||
|
|
@ -632,4 +614,4 @@ Gruppi utenti:
|
||||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
WeChat:
|
WeChat:
|
||||||
<img src="../../assets/wechat.png" alt="WeChat group QR code" width="512">
|
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="../../assets/logo.webp" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
|
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
|
||||||
|
|
||||||
|
|
@ -14,11 +14,11 @@
|
||||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||||
<br>
|
<br>
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
<a href="../../assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | **日本語** | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -34,12 +34,12 @@
|
||||||
<tr align="center">
|
<tr align="center">
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/picoclaw_mem.gif" width="360" height="240">
|
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/licheervnano.png" width="400" height="240">
|
<img src="assets/licheervnano.png" width="400" height="240">
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -56,14 +56,6 @@
|
||||||
|
|
||||||
## 📢 ニュース
|
## 📢 ニュース
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw が AliExpress で購入可能に!** [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) から LicheeRV-Claw を購入できるようになり、コンパクトな RISC-V ハードウェアで PicoClaw を試しやすくなりました。
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
|
||||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
2026-03-31 📱 **Android サポート!** PicoClawがAndroidで動作!APKは[picoclaw.io](https://picoclaw.io/download)からダウンロード
|
2026-03-31 📱 **Android サポート!** PicoClawがAndroidで動作!APKは[picoclaw.io](https://picoclaw.io/download)からダウンロード
|
||||||
|
|
||||||
2026-03-25 🚀 **v0.2.4 リリース!** Agent アーキテクチャ全面刷新(SubTurn、Hooks、Steering、EventBus)、WeChat/WeCom 統合、セキュリティ強化(.security.yml、機密データフィルタリング)、新プロバイダー(AWS Bedrock、Azure、Xiaomi MiMo)、35 件のバグ修正。PicoClaw **26K ⭐** 達成!
|
2026-03-25 🚀 **v0.2.4 リリース!** Agent アーキテクチャ全面刷新(SubTurn、Hooks、Steering、EventBus)、WeChat/WeCom 統合、セキュリティ強化(.security.yml、機密データフィルタリング)、新プロバイダー(AWS Bedrock、Azure、Xiaomi MiMo)、35 件のバグ修正。PicoClaw **26K ⭐** 達成!
|
||||||
|
|
@ -79,7 +71,7 @@
|
||||||
|
|
||||||
2026-02-26 🎉 PicoClaw がわずか 17 日で **20K スター** 達成!Channel 自動オーケストレーションとケイパビリティインターフェースが実装されました。
|
2026-02-26 🎉 PicoClaw がわずか 17 日で **20K スター** 達成!Channel 自動オーケストレーションとケイパビリティインターフェースが実装されました。
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw が 1 週間で 12K スター達成!コミュニティメンテナーの役割と[ロードマップ](../../ROADMAP.md)が正式に公開されました。
|
2026-02-16 🎉 PicoClaw が 1 週間で 12K スター達成!コミュニティメンテナーの役割と[ロードマップ](ROADMAP.md)が正式に公開されました。
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw が 4 日間で 5000 スター達成!プロジェクトロードマップと開発者グループの準備が進行中。
|
2026-02-13 🎉 PicoClaw が 4 日間で 5000 スター達成!プロジェクトロードマップと開発者グループの準備が進行中。
|
||||||
|
|
||||||
|
|
@ -116,14 +108,14 @@ _*最近のバージョンでは急速な PR マージにより 10〜20MB にな
|
||||||
| **起動時間**</br>(0.8GHz コア) | >500秒 | >30秒 | **<1秒** |
|
| **起動時間**</br>(0.8GHz コア) | >500秒 | >30秒 | **<1秒** |
|
||||||
| **コスト** | Mac Mini $599 | 大半の Linux ボード ~$50 | **あらゆる Linux ボード**</br>**最安 $10** |
|
| **コスト** | Mac Mini $599 | 大半の Linux ボード ~$50 | **あらゆる Linux ボード**</br>**最安 $10** |
|
||||||
|
|
||||||
<img src="../../assets/compare.jpg" alt="PicoClaw" width="512">
|
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **[ハードウェア互換性リスト](../guides/hardware-compatibility.ja.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください!
|
> **[ハードウェア互換性リスト](docs/ja/hardware-compatibility.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください!
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 🦾 デモンストレーション
|
## 🦾 デモンストレーション
|
||||||
|
|
@ -137,9 +129,9 @@ _*最近のバージョンでは急速な PR マージにより 10〜20MB にな
|
||||||
<th><p align="center">Web 検索&学習</p></th>
|
<th><p align="center">Web 検索&学習</p></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">開発 · デプロイ · スケール</td>
|
<td align="center">開発 · デプロイ · スケール</td>
|
||||||
|
|
@ -172,27 +164,19 @@ PicoClaw はほぼすべての Linux デバイスにデプロイできます!
|
||||||
|
|
||||||
### ソースからビルド(開発用)
|
### ソースからビルド(開発用)
|
||||||
|
|
||||||
前提条件:
|
|
||||||
|
|
||||||
- Go 1.25+
|
|
||||||
- Web UI / launcher のビルドには Node.js 22+ と pnpm 10.33.0+ が必要
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# フロントエンド依存関係をインストール
|
|
||||||
(cd web/frontend && pnpm install --frozen-lockfile)
|
|
||||||
|
|
||||||
# コアバイナリをビルド
|
# コアバイナリをビルド
|
||||||
make build
|
make build
|
||||||
|
|
||||||
# Web UI Launcher をビルド(WebUI モードに必要)
|
# Web UI Launcher をビルド(WebUI モードに必要)
|
||||||
make build-launcher
|
make build-launcher
|
||||||
|
|
||||||
# Makefile が管理するすべてのプラットフォーム向けにコアバイナリをビルド
|
# 複数プラットフォーム向けビルド
|
||||||
make build-all
|
make build-all
|
||||||
|
|
||||||
# Raspberry Pi Zero 2 W 向けビルド(32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
# Raspberry Pi Zero 2 W 向けビルド(32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||||
|
|
@ -228,7 +212,7 @@ picoclaw-launcher
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**始め方:**
|
**始め方:**
|
||||||
|
|
@ -282,7 +266,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||||
**ステップ 1:** `picoclaw-launcher` をダブルクリックすると、セキュリティ警告が表示されます:
|
**ステップ 1:** `picoclaw-launcher` をダブルクリックすると、セキュリティ警告が表示されます:
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/macos-gatekeeper-warning.jpg" alt="macOS Gatekeeper 警告" width="400">
|
<img src="assets/macos-gatekeeper-warning.jpg" alt="macOS Gatekeeper 警告" width="400">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> *"picoclaw-launcher" は開けません — "picoclaw-launcher" がMacに害を与えたりプライバシーを侵害するマルウェアを含まないことをAppleは確認できません。*
|
> *"picoclaw-launcher" は開けません — "picoclaw-launcher" がMacに害を与えたりプライバシーを侵害するマルウェアを含まないことをAppleは確認できません。*
|
||||||
|
|
@ -290,14 +274,31 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||||
**ステップ 2:** **システム設定** → **プライバシーとセキュリティ** を開き、**セキュリティ** セクションまでスクロールして **このまま開く** をクリック → ダイアログで再度 **開く** をクリックします。
|
**ステップ 2:** **システム設定** → **プライバシーとセキュリティ** を開き、**セキュリティ** セクションまでスクロールして **このまま開く** をクリック → ダイアログで再度 **開く** をクリックします。
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/macos-gatekeeper-allow.jpg" alt="macOS プライバシーとセキュリティ — このまま開く" width="600">
|
<img src="assets/macos-gatekeeper-allow.jpg" alt="macOS プライバシーとセキュリティ — このまま開く" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
この操作を一度行うと、以降の起動では警告が表示されなくなります。
|
この操作を一度行うと、以降の起動では警告が表示されなくなります。
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<a id="-run-on-old-android-phones"></a>
|
### 💻 TUI Launcher(ヘッドレス / SSH 向け推奨)
|
||||||
|
|
||||||
|
TUI(Terminal UI)Launcher は設定と管理のためのフル機能ターミナルインターフェースを提供します。サーバー、Raspberry Pi、その他のヘッドレス環境に最適です。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw-launcher-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**始め方:**
|
||||||
|
|
||||||
|
TUI メニューを使って:**1)** Provider を設定 → **2)** Channel を設定 → **3)** Gateway を起動 → **4)** チャット!
|
||||||
|
|
||||||
|
TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.io) を参照してください。
|
||||||
|
|
||||||
### 📱 Android
|
### 📱 Android
|
||||||
|
|
||||||
10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。
|
10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。
|
||||||
|
|
@ -308,10 +309,10 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td><img src="../../assets/fui_main_page.jpg" width="200"></td>
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_web_page.jpg" width="200"></td>
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_log_page.jpg" width="200"></td>
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_setting_page.jpg" width="200"></td>
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
@ -335,7 +336,7 @@ termux-chroot ./picoclaw onboard # chroot で標準的な Linux ファイル
|
||||||
|
|
||||||
その後、下記の Terminal Launcher セクションの手順に従って設定を完了してください。
|
その後、下記の Terminal Launcher セクションの手順に従って設定を完了してください。
|
||||||
|
|
||||||
<img src="../../assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||||
|
|
||||||
`picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。
|
`picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。
|
||||||
|
|
||||||
|
|
@ -351,7 +352,6 @@ picoclaw onboard
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 3,
|
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model_name": "gpt-5.4"
|
"model_name": "gpt-5.4"
|
||||||
|
|
@ -361,7 +361,7 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_keys": ["sk-your-api-key"]
|
"api_key": "sk-your-api-key"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -442,7 +442,7 @@ PicoClaw は `model_list` 設定を通じて 30 以上の LLM Provider をサポ
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Provider の完全な設定詳細は [Provider とモデル](../guides/providers.ja.md) を参照してください。
|
Provider の完全な設定詳細は [Provider とモデル](docs/ja/providers.md) を参照してください。
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -452,28 +452,28 @@ Provider の完全な設定詳細は [Provider とモデル](../guides/providers
|
||||||
|
|
||||||
| Channel | セットアップ | Protocol | ドキュメント |
|
| Channel | セットアップ | Protocol | ドキュメント |
|
||||||
|---------|------------|----------|------------|
|
|---------|------------|----------|------------|
|
||||||
| **Telegram** | 簡単(bot トークン) | Long polling | [ガイド](../channels/telegram/README.ja.md) |
|
| **Telegram** | 簡単(bot トークン) | Long polling | [ガイド](docs/channels/telegram/README.ja.md) |
|
||||||
| **Discord** | 簡単(bot トークン + intents) | WebSocket | [ガイド](../channels/discord/README.ja.md) |
|
| **Discord** | 簡単(bot トークン + intents) | WebSocket | [ガイド](docs/channels/discord/README.ja.md) |
|
||||||
| **WhatsApp** | 簡単(QR スキャンまたは bridge URL) | Native / Bridge | [ガイド](../guides/chat-apps.ja.md#whatsapp) |
|
| **WhatsApp** | 簡単(QR スキャンまたは bridge URL) | Native / Bridge | [ガイド](docs/ja/chat-apps.md#whatsapp) |
|
||||||
| **微信 (Weixin)** | 簡単(QR スキャン) | iLink API | [ガイド](../guides/chat-apps.ja.md#weixin) |
|
| **微信 (Weixin)** | 簡単(QR スキャン) | iLink API | [ガイド](docs/ja/chat-apps.md#weixin) |
|
||||||
| **QQ** | 簡単(AppID + AppSecret) | WebSocket | [ガイド](../channels/qq/README.ja.md) |
|
| **QQ** | 簡単(AppID + AppSecret) | WebSocket | [ガイド](docs/channels/qq/README.ja.md) |
|
||||||
| **Slack** | 簡単(bot + app トークン) | Socket Mode | [ガイド](../channels/slack/README.ja.md) |
|
| **Slack** | 簡単(bot + app トークン) | Socket Mode | [ガイド](docs/channels/slack/README.ja.md) |
|
||||||
| **Matrix** | 中級(homeserver + トークン) | Sync API | [ガイド](../channels/matrix/README.ja.md) |
|
| **Matrix** | 中級(homeserver + トークン) | Sync API | [ガイド](docs/channels/matrix/README.ja.md) |
|
||||||
| **DingTalk** | 中級(クライアント認証情報) | Stream | [ガイド](../channels/dingtalk/README.ja.md) |
|
| **DingTalk** | 中級(クライアント認証情報) | Stream | [ガイド](docs/channels/dingtalk/README.ja.md) |
|
||||||
| **Feishu / Lark** | 中級(App ID + Secret) | WebSocket/SDK | [ガイド](../channels/feishu/README.ja.md) |
|
| **Feishu / Lark** | 中級(App ID + Secret) | WebSocket/SDK | [ガイド](docs/channels/feishu/README.ja.md) |
|
||||||
| **LINE** | 中級(認証情報 + webhook) | Webhook | [ガイド](../channels/line/README.ja.md) |
|
| **LINE** | 中級(認証情報 + webhook) | Webhook | [ガイド](docs/channels/line/README.ja.md) |
|
||||||
| **WeCom** | 簡単(QR ログインまたは手動) | WebSocket | [ガイド](../channels/wecom/README.ja.md) |
|
| **WeCom** | 簡単(QR ログインまたは手動) | WebSocket | [ガイド](docs/channels/wecom/README.md) |
|
||||||
| **IRC** | 中級(サーバー + nick) | IRC protocol | [ガイド](../guides/chat-apps.ja.md#irc) |
|
| **IRC** | 中級(サーバー + nick) | IRC protocol | [ガイド](docs/ja/chat-apps.md#irc) |
|
||||||
| **OneBot** | 中級(WebSocket URL) | OneBot v11 | [ガイド](../channels/onebot/README.ja.md) |
|
| **OneBot** | 中級(WebSocket URL) | OneBot v11 | [ガイド](docs/channels/onebot/README.ja.md) |
|
||||||
| **MaixCam** | 簡単(有効化) | TCP socket | [ガイド](../channels/maixcam/README.ja.md) |
|
| **MaixCam** | 簡単(有効化) | TCP socket | [ガイド](docs/channels/maixcam/README.ja.md) |
|
||||||
| **Pico** | 簡単(有効化) | Native protocol | 内蔵 |
|
| **Pico** | 簡単(有効化) | Native protocol | 内蔵 |
|
||||||
| **Pico Client** | 簡単(WebSocket URL) | WebSocket | 内蔵 |
|
| **Pico Client** | 簡単(WebSocket URL) | WebSocket | 内蔵 |
|
||||||
|
|
||||||
> webhook ベースのすべての Channel は単一の Gateway HTTP サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)を共有します。Feishu は WebSocket/SDK モードを使用し、共有 HTTP サーバーを使用しません。
|
> webhook ベースのすべての Channel は単一の Gateway HTTP サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)を共有します。Feishu は WebSocket/SDK モードを使用し、共有 HTTP サーバーを使用しません。
|
||||||
|
|
||||||
> ログの詳細度は `gateway.log_level` で制御します(デフォルト:`warn`)。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。`PICOCLAW_LOG_LEVEL` 環境変数でも設定可能です。詳細は[設定ガイド](../guides/configuration.ja.md#gateway-ログレベル)を参照してください。
|
> ログの詳細度は `gateway.log_level` で制御します(デフォルト:`warn`)。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。`PICOCLAW_LOG_LEVEL` 環境変数でも設定可能です。詳細は[設定ガイド](docs/ja/configuration.md#gateway-ログレベル)を参照してください。
|
||||||
|
|
||||||
Channel の詳細なセットアップ手順は [チャットアプリ設定](../guides/chat-apps.ja.md) を参照してください。
|
Channel の詳細なセットアップ手順は [チャットアプリ設定](docs/ja/chat-apps.md) を参照してください。
|
||||||
|
|
||||||
## 🔧 ツール
|
## 🔧 ツール
|
||||||
|
|
||||||
|
|
@ -484,7 +484,7 @@ PicoClaw は最新情報を提供するために Web を検索できます。`to
|
||||||
| 検索エンジン | API キー | 無料枠 | リンク |
|
| 検索エンジン | API キー | 無料枠 | リンク |
|
||||||
|------------|---------|--------|-------|
|
|------------|---------|--------|-------|
|
||||||
| DuckDuckGo | 不要 | 無制限 | 内蔵フォールバック |
|
| DuckDuckGo | 不要 | 無制限 | 内蔵フォールバック |
|
||||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必須 | 1500 クエリ/月(日次割り当て) | AI 搭載、中国語に最適化 |
|
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必須 | 1000 クエリ/日 | AI 搭載、中国語に最適化 |
|
||||||
| [Tavily](https://tavily.com) | 必須 | 1000 クエリ/月 | AI Agent 向けに最適化 |
|
| [Tavily](https://tavily.com) | 必須 | 1000 クエリ/月 | AI Agent 向けに最適化 |
|
||||||
| [Brave Search](https://brave.com/search/api) | 必須 | 2000 クエリ/月 | 高速でプライベート |
|
| [Brave Search](https://brave.com/search/api) | 必須 | 2000 クエリ/月 | 高速でプライベート |
|
||||||
| [Perplexity](https://www.perplexity.ai) | 必須 | 有料 | AI 搭載検索 |
|
| [Perplexity](https://www.perplexity.ai) | 必須 | 有料 | AI 搭載検索 |
|
||||||
|
|
@ -493,7 +493,7 @@ PicoClaw は最新情報を提供するために Web を検索できます。`to
|
||||||
|
|
||||||
### ⚙️ その他のツール
|
### ⚙️ その他のツール
|
||||||
|
|
||||||
PicoClaw にはファイル操作、コード実行、スケジューリングなどの組み込みツールが含まれています。詳細は [ツール設定](../reference/tools_configuration.ja.md) を参照してください。
|
PicoClaw にはファイル操作、コード実行、スケジューリングなどの組み込みツールが含まれています。詳細は [ツール設定](docs/ja/tools_configuration.md) を参照してください。
|
||||||
|
|
||||||
## 🎯 Skill
|
## 🎯 Skill
|
||||||
|
|
||||||
|
|
@ -523,7 +523,7 @@ picoclaw skills install <skill-name>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
詳細は [ツール設定 - Skill](../reference/tools_configuration.ja.md#skills-tool) を参照してください。
|
詳細は [ツール設定 - Skill](docs/ja/tools_configuration.md#skills-tool) を参照してください。
|
||||||
|
|
||||||
## 🔗 MCP(Model Context Protocol)
|
## 🔗 MCP(Model Context Protocol)
|
||||||
|
|
||||||
|
|
@ -546,9 +546,9 @@ PicoClaw は [MCP](https://modelcontextprotocol.io/) をネイティブサポー
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
MCP の完全な設定(stdio、SSE、HTTP トランスポート、Tool Discovery)は [ツール設定 - MCP](../reference/tools_configuration.ja.md#mcp-tool) を参照してください。
|
MCP の完全な設定(stdio、SSE、HTTP トランスポート、Tool Discovery)は [ツール設定 - MCP](docs/ja/tools_configuration.md#mcp-tool) を参照してください。
|
||||||
|
|
||||||
## <img src="../../assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> エージェントソーシャルネットワークに参加
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> エージェントソーシャルネットワークに参加
|
||||||
|
|
||||||
CLI または統合チャットアプリからメッセージを 1 つ送るだけで、PicoClaw をエージェントソーシャルネットワークに接続できます。
|
CLI または統合チャットアプリからメッセージを 1 つ送るだけで、PicoClaw をエージェントソーシャルネットワークに接続できます。
|
||||||
|
|
||||||
|
|
@ -589,23 +589,23 @@ PicoClaw は `cron` ツールによるスケジュールリマインダーと定
|
||||||
|
|
||||||
| トピック | 説明 |
|
| トピック | 説明 |
|
||||||
|---------|------|
|
|---------|------|
|
||||||
| [Docker & クイックスタート](../guides/docker.ja.md) | Docker Compose セットアップ、Launcher/Agent モード |
|
| [Docker & クイックスタート](docs/ja/docker.md) | Docker Compose セットアップ、Launcher/Agent モード |
|
||||||
| [チャットアプリ](../guides/chat-apps.ja.md) | 17 以上の Channel セットアップガイド |
|
| [チャットアプリ](docs/ja/chat-apps.md) | 17 以上の Channel セットアップガイド |
|
||||||
| [設定](../guides/configuration.ja.md) | 環境変数、ワークスペース構成、セキュリティサンドボックス |
|
| [設定](docs/ja/configuration.md) | 環境変数、ワークスペース構成、セキュリティサンドボックス |
|
||||||
| [Provider とモデル](../guides/providers.ja.md) | 30 以上の LLM Provider、モデルルーティング、model_list 設定 |
|
| [Provider とモデル](docs/ja/providers.md) | 30 以上の LLM Provider、モデルルーティング、model_list 設定 |
|
||||||
| [Spawn & 非同期タスク](../guides/spawn-tasks.ja.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション |
|
| [Spawn & 非同期タスク](docs/ja/spawn-tasks.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション |
|
||||||
| [Hook システム](../architecture/hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook |
|
| [Hook システム](docs/hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook |
|
||||||
| [Steering](../architecture/steering.md) | 実行中の Agent ループにメッセージを注入 |
|
| [Steering](docs/steering.md) | 実行中の Agent ループにメッセージを注入 |
|
||||||
| [SubTurn](../architecture/subturn.md) | サブ Agent の調整、並行制御、ライフサイクル |
|
| [SubTurn](docs/subturn.md) | サブ Agent の調整、並行制御、ライフサイクル |
|
||||||
| [トラブルシューティング](../operations/troubleshooting.ja.md) | よくある問題と解決策 |
|
| [トラブルシューティング](docs/ja/troubleshooting.md) | よくある問題と解決策 |
|
||||||
| [ツール設定](../reference/tools_configuration.ja.md) | ツールごとの有効/無効、exec ポリシー、MCP、Skill |
|
| [ツール設定](docs/ja/tools_configuration.md) | ツールごとの有効/無効、exec ポリシー、MCP、Skill |
|
||||||
| [ハードウェア互換性](../guides/hardware-compatibility.ja.md) | テスト済みボード、最小要件 |
|
| [ハードウェア互換性](docs/ja/hardware-compatibility.md) | テスト済みボード、最小要件 |
|
||||||
|
|
||||||
## 🤝 コントリビュート&ロードマップ
|
## 🤝 コントリビュート&ロードマップ
|
||||||
|
|
||||||
PR 歓迎!コードベースは意図的に小さく読みやすくしています。
|
PR 歓迎!コードベースは意図的に小さく読みやすくしています。
|
||||||
|
|
||||||
[コミュニティロードマップ](https://github.com/sipeed/picoclaw/issues/988)と[CONTRIBUTING.md](../../CONTRIBUTING.md)をご覧ください。
|
[コミュニティロードマップ](https://github.com/sipeed/picoclaw/issues/988)と[CONTRIBUTING.md](CONTRIBUTING.md)をご覧ください。
|
||||||
|
|
||||||
開発者グループ構築中、最初の PR がマージされたら参加できます!
|
開発者グループ構築中、最初の PR がマージされたら参加できます!
|
||||||
|
|
||||||
|
|
@ -614,4 +614,4 @@ PR 歓迎!コードベースは意図的に小さく読みやすくしてい
|
||||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
WeChat:
|
WeChat:
|
||||||
<img src="../../assets/wechat.png" alt="WeChat group QR code" width="512">
|
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||||
133
README.md
133
README.md
|
|
@ -18,7 +18,7 @@
|
||||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](docs/project/README.zh.md) | [日本語](docs/project/README.ja.md) | [한국어](docs/project/README.ko.md) | [Português](docs/project/README.pt-br.md) | [Tiếng Việt](docs/project/README.vi.md) | [Français](docs/project/README.fr.md) | [Italiano](docs/project/README.it.md) | [Bahasa Indonesia](docs/project/README.id.md) | [Malay](docs/project/README.ms.md) | **English**
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | **English**
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -56,14 +56,6 @@
|
||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw on AliExpress!** You can now purchase LicheeRV-Claw from [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), making it easier to try PicoClaw on compact RISC-V hardware.
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
|
||||||
<img src="assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
2026-03-31 📱 **Android Support!** PicoClaw now runs on Android! Download the APK at [picoclaw.io](https://picoclaw.io/download)
|
2026-03-31 📱 **Android Support!** PicoClaw now runs on Android! Download the APK at [picoclaw.io](https://picoclaw.io/download)
|
||||||
|
|
||||||
2026-03-25 🚀 **v0.2.4 Released!** Agent architecture overhaul (SubTurn, Hooks, Steering, EventBus), WeChat/WeCom integration, security hardening (.security.yml, sensitive data filtering), new providers (AWS Bedrock, Azure, Xiaomi MiMo), and 35 bug fixes. PicoClaw has reached **26K Stars**!
|
2026-03-25 🚀 **v0.2.4 Released!** Agent architecture overhaul (SubTurn, Hooks, Steering, EventBus), WeChat/WeCom integration, security hardening (.security.yml, sensitive data filtering), new providers (AWS Bedrock, Azure, Xiaomi MiMo), and 35 bug fixes. PicoClaw has reached **26K Stars**!
|
||||||
|
|
@ -120,7 +112,7 @@ _*Recent builds may use 10-20MB due to rapid PR merges. Resource optimization is
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **[Hardware Compatibility List](docs/guides/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR!
|
> **[Hardware Compatibility List](docs/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR!
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||||
|
|
@ -172,32 +164,22 @@ Alternatively, download the binary for your platform from the [GitHub Releases](
|
||||||
|
|
||||||
### Build from source (for development)
|
### Build from source (for development)
|
||||||
|
|
||||||
Prerequisites:
|
|
||||||
|
|
||||||
- Go 1.25+
|
|
||||||
- Node.js 22+ and pnpm 10.33.0+ for Web UI / launcher builds
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Install frontend dependencies
|
# Build core binary
|
||||||
(cd web/frontend && pnpm install --frozen-lockfile)
|
|
||||||
|
|
||||||
# Build the core binary for the current platform
|
|
||||||
make build
|
make build
|
||||||
|
|
||||||
# Build the Web UI Launcher (required for WebUI mode)
|
# Build Web UI Launcher (required for WebUI mode)
|
||||||
make build-launcher
|
make build-launcher
|
||||||
|
|
||||||
# Build core binaries for all Makefile-managed platforms
|
# Build for multiple platforms
|
||||||
make build-all
|
make build-all
|
||||||
|
|
||||||
# Build for Raspberry Pi Zero 2 W
|
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||||
# 32-bit: make build-linux-arm
|
|
||||||
# 64-bit: make build-linux-arm64
|
|
||||||
make build-pi-zero
|
make build-pi-zero
|
||||||
|
|
||||||
# Build and install
|
# Build and install
|
||||||
|
|
@ -233,7 +215,7 @@ picoclaw-launcher
|
||||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Getting started:**
|
**Getting started:**
|
||||||
|
|
||||||
Open the WebUI, then: **1)** Configure a Provider (add your LLM API key) -> **2)** Configure a Channel (e.g., Telegram) -> **3)** Start the Gateway -> **4)** Chat!
|
Open the WebUI, then: **1)** Configure a Provider (add your LLM API key) -> **2)** Configure a Channel (e.g., Telegram) -> **3)** Start the Gateway -> **4)** Chat!
|
||||||
|
|
||||||
|
|
@ -299,7 +281,24 @@ After this one-time step, `picoclaw-launcher` will open normally on subsequent l
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<a id="-run-on-old-android-phones"></a>
|
### 💻 TUI Launcher (Recommended for Headless / SSH)
|
||||||
|
|
||||||
|
The TUI (Terminal UI) Launcher provides a full-featured terminal interface for configuration and management. Ideal for servers, Raspberry Pi, and other headless environments.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw-launcher-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Getting started:**
|
||||||
|
|
||||||
|
Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel -> **3)** Start the Gateway -> **4)** Chat!
|
||||||
|
|
||||||
|
For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||||
|
|
||||||
### 📱 Android
|
### 📱 Android
|
||||||
|
|
||||||
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw.
|
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw.
|
||||||
|
|
@ -369,8 +368,8 @@ This creates `~/.picoclaw/config.json` and the workspace directory.
|
||||||
```
|
```
|
||||||
|
|
||||||
> See `config/config.example.json` in the repo for a complete configuration template with all available options.
|
> See `config/config.example.json` in the repo for a complete configuration template with all available options.
|
||||||
>
|
>
|
||||||
> Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security/security_configuration.md` for more details.
|
> Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security_configuration.md` for more details.
|
||||||
|
|
||||||
|
|
||||||
**3. Chat**
|
**3. Chat**
|
||||||
|
|
@ -449,20 +448,20 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
For full provider configuration details, see [Providers & Models](docs/guides/providers.md).
|
For full provider configuration details, see [Providers & Models](docs/providers.md).
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## 💬 Channels (Chat Apps)
|
## 💬 Channels (Chat Apps)
|
||||||
|
|
||||||
Talk to your PicoClaw through 19+ messaging platforms:
|
Talk to your PicoClaw through 18+ messaging platforms:
|
||||||
|
|
||||||
| Channel | Setup | Protocol | Docs |
|
| Channel | Setup | Protocol | Docs |
|
||||||
|---------|-------|----------|------|
|
|---------|-------|----------|------|
|
||||||
| **Telegram** | Easy (bot token) | Long polling | [Guide](docs/channels/telegram/README.md) |
|
| **Telegram** | Easy (bot token) | Long polling | [Guide](docs/channels/telegram/README.md) |
|
||||||
| **Discord** | Easy (bot token + intents) | WebSocket | [Guide](docs/channels/discord/README.md) |
|
| **Discord** | Easy (bot token + intents) | WebSocket | [Guide](docs/channels/discord/README.md) |
|
||||||
| **WhatsApp** | Easy (QR scan or bridge URL) | Native / Bridge | [Guide](docs/guides/chat-apps.md#whatsapp) |
|
| **WhatsApp** | Easy (QR scan or bridge URL) | Native / Bridge | [Guide](docs/chat-apps.md#whatsapp) |
|
||||||
| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/guides/chat-apps.md#weixin) |
|
| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/chat-apps.md#weixin) |
|
||||||
| **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) |
|
| **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) |
|
||||||
| **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/README.md) |
|
| **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/README.md) |
|
||||||
| **Matrix** | Medium (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.md) |
|
| **Matrix** | Medium (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.md) |
|
||||||
|
|
@ -471,18 +470,17 @@ Talk to your PicoClaw through 19+ messaging platforms:
|
||||||
| **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) |
|
| **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) |
|
||||||
| **WeCom** | Easy (QR login or manual) | WebSocket | [Guide](docs/channels/wecom/README.md) |
|
| **WeCom** | Easy (QR login or manual) | WebSocket | [Guide](docs/channels/wecom/README.md) |
|
||||||
| **VK** | Easy (group token) | Long Poll | [Guide](docs/channels/vk/README.md) |
|
| **VK** | Easy (group token) | Long Poll | [Guide](docs/channels/vk/README.md) |
|
||||||
| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/guides/chat-apps.md#irc) |
|
| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/chat-apps.md#irc) |
|
||||||
| **OneBot** | Medium (WebSocket URL) | OneBot v11 | [Guide](docs/channels/onebot/README.md) |
|
| **OneBot** | Medium (WebSocket URL) | OneBot v11 | [Guide](docs/channels/onebot/README.md) |
|
||||||
| **MQTT** | Easy (broker + agent_id) | MQTT pub/sub | [Guide](docs/channels/mqtt/README.md) |
|
|
||||||
| **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) |
|
| **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) |
|
||||||
| **Pico** | Easy (enable) | Native protocol | Built-in |
|
| **Pico** | Easy (enable) | Native protocol | Built-in |
|
||||||
| **Pico Client** | Easy (WebSocket URL) | WebSocket | Built-in |
|
| **Pico Client** | Easy (WebSocket URL) | WebSocket | Built-in |
|
||||||
|
|
||||||
> All webhook-based channels share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu uses WebSocket/SDK mode and does not use the shared HTTP server.
|
> All webhook-based channels share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu uses WebSocket/SDK mode and does not use the shared HTTP server.
|
||||||
|
|
||||||
> Log verbosity is controlled by `gateway.log_level` (default: `warn`). Supported values: `debug`, `info`, `warn`, `error`, `fatal`. Can also be set via `PICOCLAW_LOG_LEVEL`. See [Configuration](docs/guides/configuration.md#gateway-log-level) for details.
|
> Log verbosity is controlled by `gateway.log_level` (default: `warn`). Supported values: `debug`, `info`, `warn`, `error`, `fatal`. Can also be set via `PICOCLAW_LOG_LEVEL`. See [Configuration](docs/configuration.md#gateway-log-level) for details.
|
||||||
|
|
||||||
For detailed channel setup instructions, see [Chat Apps Configuration](docs/guides/chat-apps.md).
|
For detailed channel setup instructions, see [Chat Apps Configuration](docs/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Tools
|
## 🔧 Tools
|
||||||
|
|
||||||
|
|
@ -493,8 +491,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too
|
||||||
| Search Engine | API Key | Free Tier | Link |
|
| Search Engine | API Key | Free Tier | Link |
|
||||||
|--------------|---------|-----------|------|
|
|--------------|---------|-----------|------|
|
||||||
| DuckDuckGo | Not needed | Unlimited | Built-in fallback |
|
| DuckDuckGo | Not needed | Unlimited | Built-in fallback |
|
||||||
| [Gemini Google Search](https://aistudio.google.com/apikey) | Required | Varies | Gemini with Google Search grounding |
|
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1000 queries/day | AI-powered, China-optimized |
|
||||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1500/month (daily allocation) | AI-powered, China-optimized |
|
|
||||||
| [Tavily](https://tavily.com) | Required | 1000 queries/month | Optimized for AI Agents |
|
| [Tavily](https://tavily.com) | Required | 1000 queries/month | Optimized for AI Agents |
|
||||||
| [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private |
|
| [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private |
|
||||||
| [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search |
|
| [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search |
|
||||||
|
|
@ -503,7 +500,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too
|
||||||
|
|
||||||
### ⚙️ Other Tools
|
### ⚙️ Other Tools
|
||||||
|
|
||||||
PicoClaw includes built-in tools for file operations, code execution, scheduling, and more. See [Tools Configuration](docs/reference/tools_configuration.md) for details.
|
PicoClaw includes built-in tools for file operations, code execution, scheduling, and more. See [Tools Configuration](docs/tools_configuration.md) for details.
|
||||||
|
|
||||||
## 🎯 Skills
|
## 🎯 Skills
|
||||||
|
|
||||||
|
|
@ -516,7 +513,7 @@ picoclaw skills search "web scraping"
|
||||||
picoclaw skills install <skill-name>
|
picoclaw skills install <skill-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configure skill registries**:
|
**Configure ClawHub token** (optional, for higher rate limits):
|
||||||
|
|
||||||
Add to your `config.json`:
|
Add to your `config.json`:
|
||||||
```json
|
```json
|
||||||
|
|
@ -526,11 +523,6 @@ Add to your `config.json`:
|
||||||
"registries": {
|
"registries": {
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"auth_token": "your-clawhub-token"
|
"auth_token": "your-clawhub-token"
|
||||||
},
|
|
||||||
"github": {
|
|
||||||
"base_url": "https://github.com",
|
|
||||||
"auth_token": "your-github-token",
|
|
||||||
"proxy": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -538,9 +530,7 @@ Add to your `config.json`:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`tools.skills.github.*` is deprecated. Use `tools.skills.registries.github.*` instead.
|
For more details, see [Tools Configuration - Skills](docs/tools_configuration.md#skills-tool).
|
||||||
|
|
||||||
For more details, see [Tools Configuration - Skills](docs/reference/tools_configuration.md#skills-tool).
|
|
||||||
|
|
||||||
## 🔗 MCP (Model Context Protocol)
|
## 🔗 MCP (Model Context Protocol)
|
||||||
|
|
||||||
|
|
@ -563,20 +553,7 @@ PicoClaw natively supports [MCP](https://modelcontextprotocol.io/) — connect a
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
You can manage common MCP setups directly from the CLI instead of editing JSON by hand:
|
For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/tools_configuration.md#mcp-tool).
|
||||||
|
|
||||||
```bash
|
|
||||||
picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp
|
|
||||||
picoclaw mcp list
|
|
||||||
picoclaw mcp test filesystem
|
|
||||||
```
|
|
||||||
|
|
||||||
`picoclaw mcp` is a configuration manager: it updates `config.json` under `tools.mcp.servers`, but it does not keep the server process running itself.
|
|
||||||
|
|
||||||
Use `picoclaw mcp edit` when you need advanced fields that are not covered by `picoclaw mcp add`.
|
|
||||||
For example, `picoclaw mcp add` supports `--deferred` and `--env-file`, while `picoclaw mcp edit` is still useful for direct JSON editing and uncommon MCP settings.
|
|
||||||
|
|
||||||
For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/reference/tools_configuration.md#mcp-tool). For CLI usage and examples, see [MCP Server CLI](docs/reference/mcp-cli.md).
|
|
||||||
|
|
||||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
|
||||||
|
|
||||||
|
|
@ -596,11 +573,6 @@ Connect PicoClaw to the Agent Social Network simply by sending a single message
|
||||||
| `picoclaw status` | Show status |
|
| `picoclaw status` | Show status |
|
||||||
| `picoclaw version` | Show version info |
|
| `picoclaw version` | Show version info |
|
||||||
| `picoclaw model` | View or switch the default model |
|
| `picoclaw model` | View or switch the default model |
|
||||||
| `picoclaw mcp list` | List configured MCP servers |
|
|
||||||
| `picoclaw mcp add ...` | Add or update an MCP server entry |
|
|
||||||
| `picoclaw mcp test` | Probe a configured MCP server |
|
|
||||||
| `picoclaw mcp edit` | Open config for advanced MCP editing |
|
|
||||||
| `picoclaw mcp remove` | Remove an MCP server entry |
|
|
||||||
| `picoclaw cron list` | List all scheduled jobs |
|
| `picoclaw cron list` | List all scheduled jobs |
|
||||||
| `picoclaw cron add ...` | Add a scheduled job |
|
| `picoclaw cron add ...` | Add a scheduled job |
|
||||||
| `picoclaw cron disable` | Disable a scheduled job |
|
| `picoclaw cron disable` | Disable a scheduled job |
|
||||||
|
|
@ -618,7 +590,7 @@ PicoClaw supports scheduled reminders and recurring tasks through the `cron` too
|
||||||
* **Recurring tasks**: "Remind me every 2 hours" -> triggers every 2 hours
|
* **Recurring tasks**: "Remind me every 2 hours" -> triggers every 2 hours
|
||||||
* **Cron expressions**: "Remind me at 9am daily" -> uses cron expression
|
* **Cron expressions**: "Remind me at 9am daily" -> uses cron expression
|
||||||
|
|
||||||
See [docs/reference/cron.md](docs/reference/cron.md) for current schedule types, execution modes, command-job gates, and persistence details.
|
See [docs/cron.md](docs/cron.md) for current schedule types, execution modes, command-job gates, and persistence details.
|
||||||
|
|
||||||
## 📚 Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
|
|
@ -626,19 +598,18 @@ For detailed guides beyond this README:
|
||||||
|
|
||||||
| Topic | Description |
|
| Topic | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| [Docker & Quick Start](docs/guides/docker.md) | Docker Compose setup, Launcher/Agent modes |
|
| [Docker & Quick Start](docs/docker.md) | Docker Compose setup, Launcher/Agent modes |
|
||||||
| [Chat Apps](docs/guides/chat-apps.md) | All 18+ channel setup guides |
|
| [Chat Apps](docs/chat-apps.md) | All 17+ channel setup guides |
|
||||||
| [Configuration](docs/guides/configuration.md) | Environment variables, workspace layout, security sandbox |
|
| [Configuration](docs/configuration.md) | Environment variables, workspace layout, security sandbox |
|
||||||
| [MCP Server CLI](docs/reference/mcp-cli.md) | Add, list, test, edit, and remove MCP server entries from the CLI |
|
| [Scheduled Tasks and Cron Jobs](docs/cron.md) | Cron schedule types, deliver modes, command gates, job storage |
|
||||||
| [Scheduled Tasks and Cron Jobs](docs/reference/cron.md) | Cron schedule types, deliver modes, command gates, job storage |
|
| [Providers & Models](docs/providers.md) | 30+ LLM providers, model routing, model_list configuration |
|
||||||
| [Providers & Models](docs/guides/providers.md) | 30+ LLM providers, model routing, model_list configuration |
|
| [Spawn & Async Tasks](docs/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration |
|
||||||
| [Spawn & Async Tasks](docs/guides/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration |
|
| [Hooks](docs/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks |
|
||||||
| [Hooks](docs/architecture/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks |
|
| [Steering](docs/steering.md) | Inject messages into a running agent loop between tool calls |
|
||||||
| [Steering](docs/architecture/steering.md) | Inject messages into a running agent loop between tool calls |
|
| [SubTurn](docs/subturn.md) | Subagent coordination, concurrency control, lifecycle |
|
||||||
| [SubTurn](docs/architecture/subturn.md) | Subagent coordination, concurrency control, lifecycle |
|
| [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions |
|
||||||
| [Troubleshooting](docs/operations/troubleshooting.md) | Common issues and solutions |
|
| [Tools Configuration](docs/tools_configuration.md) | Per-tool enable/disable, exec policies, MCP, Skills |
|
||||||
| [Tools Configuration](docs/reference/tools_configuration.md) | Per-tool enable/disable, exec policies, MCP, Skills |
|
| [Hardware Compatibility](docs/hardware-compatibility.md) | Tested boards, minimum requirements |
|
||||||
| [Hardware Compatibility](docs/guides/hardware-compatibility.md) | Tested boards, minimum requirements |
|
|
||||||
|
|
||||||
## 🤝 Contribute & Roadmap
|
## 🤝 Contribute & Roadmap
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="../../assets/logo.webp" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: Pembantu AI Ultra-Cekap dalam Go</h1>
|
<h1>PicoClaw: Pembantu AI Ultra-Cekap dalam Go</h1>
|
||||||
|
|
||||||
|
|
@ -14,11 +14,11 @@
|
||||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||||
<br>
|
<br>
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
<a href="../../assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](README.md)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -34,12 +34,12 @@
|
||||||
<tr align="center">
|
<tr align="center">
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/picoclaw_mem.gif" width="360" height="240">
|
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/licheervnano.png" width="400" height="240">
|
<img src="assets/licheervnano.png" width="400" height="240">
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -56,14 +56,6 @@
|
||||||
|
|
||||||
## 📢 Berita
|
## 📢 Berita
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Anda kini boleh membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), menjadikannya lebih mudah untuk mencuba PicoClaw pada perkakasan RISC-V yang kompak.
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
|
||||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
2026-03-31 📱 **Sokongan Android!** PicoClaw sekarang berjalan di Android! Muat turun APK di [picoclaw.io](https://picoclaw.io/download)
|
2026-03-31 📱 **Sokongan Android!** PicoClaw sekarang berjalan di Android! Muat turun APK di [picoclaw.io](https://picoclaw.io/download)
|
||||||
|
|
||||||
2026-03-25 🚀 **v0.2.4 Dikeluarkan!** Penstrukturan semula seni bina Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keselamatan (.security.yml, penapisan data sensitif), penyedia baharu (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 pembetulan pepijat. PicoClaw mencapai **26K Stars**!
|
2026-03-25 🚀 **v0.2.4 Dikeluarkan!** Penstrukturan semula seni bina Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keselamatan (.security.yml, penapisan data sensitif), penyedia baharu (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 pembetulan pepijat. PicoClaw mencapai **26K Stars**!
|
||||||
|
|
@ -79,7 +71,7 @@
|
||||||
|
|
||||||
2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi saluran automatik dan antara muka keupayaan kini aktif.
|
2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi saluran automatik dan antara muka keupayaan kini aktif.
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw melepasi 12K Stars dalam seminggu! Peranan penyelenggara komuniti dan [Peta Jalan](../../ROADMAP.md) dilancarkan secara rasmi.
|
2026-02-16 🎉 PicoClaw melepasi 12K Stars dalam seminggu! Peranan penyelenggara komuniti dan [Peta Jalan](ROADMAP.md) dilancarkan secara rasmi.
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw melepasi 5000 Stars dalam 4 hari! Peta jalan projek dan kumpulan pembangun sedang dalam proses.
|
2026-02-13 🎉 PicoClaw melepasi 5000 Stars dalam 4 hari! Peta jalan projek dan kumpulan pembangun sedang dalam proses.
|
||||||
|
|
||||||
|
|
@ -116,14 +108,14 @@ _*Binaan terkini mungkin menggunakan 10-20MB disebabkan penggabungan PR yang pes
|
||||||
| **Masa Boot** (teras 0.8GHz) | >500s | >30s | **<1s** |
|
| **Masa Boot** (teras 0.8GHz) | >500s | >30s | **<1s** |
|
||||||
| **Kos** | Mac Mini $599 | Kebanyakan papan Linux ~$50 | **Mana-mana papan Linux dari $10** |
|
| **Kos** | Mac Mini $599 | Kebanyakan papan Linux ~$50 | **Mana-mana papan Linux dari $10** |
|
||||||
|
|
||||||
<img src="../../assets/compare.jpg" alt="PicoClaw" width="512">
|
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **[Senarai Keserasian Perkakasan](../guides/hardware-compatibility.md)** — Lihat semua papan yang diuji, dari RISC-V $5 hingga Raspberry Pi hingga telefon Android.
|
> **[Senarai Keserasian Perkakasan](docs/hardware-compatibility.md)** — Lihat semua papan yang diuji, dari RISC-V $5 hingga Raspberry Pi hingga telefon Android.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/hardware-banner.jpg" alt="Keserasian Perkakasan PicoClaw" width="100%">
|
<img src="assets/hardware-banner.jpg" alt="Keserasian Perkakasan PicoClaw" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 🦾 Demonstrasi
|
## 🦾 Demonstrasi
|
||||||
|
|
@ -137,9 +129,9 @@ _*Binaan terkini mungkin menggunakan 10-20MB disebabkan penggabungan PR yang pes
|
||||||
<th><p align="center">Carian Web & Pembelajaran</p></th>
|
<th><p align="center">Carian Web & Pembelajaran</p></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">Bangun · Deploy · Skala</td>
|
<td align="center">Bangun · Deploy · Skala</td>
|
||||||
|
|
@ -173,26 +165,18 @@ Muat turun binari untuk platform anda dari halaman [GitHub Releases](https://git
|
||||||
|
|
||||||
### Bina dari sumber (untuk pembangunan)
|
### Bina dari sumber (untuk pembangunan)
|
||||||
|
|
||||||
Prasyarat:
|
|
||||||
|
|
||||||
- Go 1.25+
|
|
||||||
- Node.js 22+ dan pnpm 10.33.0+ untuk binaan Web UI / launcher
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Pasang dependensi frontend
|
|
||||||
(cd web/frontend && pnpm install --frozen-lockfile)
|
|
||||||
|
|
||||||
# Bina binari teras
|
# Bina binari teras
|
||||||
make build
|
make build
|
||||||
|
|
||||||
# Bina Pelancar Web UI (diperlukan untuk mod WebUI)
|
# Bina Pelancar Web UI (diperlukan untuk mod WebUI)
|
||||||
make build-launcher
|
make build-launcher
|
||||||
|
|
||||||
# Bina binari teras untuk semua platform yang diuruskan oleh Makefile
|
# Bina untuk pelbagai platform
|
||||||
make build-all
|
make build-all
|
||||||
|
|
||||||
# Bina untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
# Bina untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||||
|
|
@ -228,7 +212,7 @@ picoclaw-launcher
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/launcher-webui.jpg" alt="Pelancar WebUI" width="600">
|
<img src="assets/launcher-webui.jpg" alt="Pelancar WebUI" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Memulakan:** Buka WebUI, kemudian: **1)** Konfigurasikan Penyedia (tambah kunci API LLM) -> **2)** Konfigurasikan Saluran (cth. Telegram) -> **3)** Mulakan Gateway -> **4)** Sembang!
|
**Memulakan:** Buka WebUI, kemudian: **1)** Konfigurasikan Penyedia (tambah kunci API LLM) -> **2)** Konfigurasikan Saluran (cth. Telegram) -> **3)** Mulakan Gateway -> **4)** Sembang!
|
||||||
|
|
@ -279,7 +263,7 @@ macOS mungkin menyekat `picoclaw-launcher` pada pelancaran pertama kerana ia dim
|
||||||
**Langkah 1:** Klik dua kali `picoclaw-launcher`. Anda akan melihat amaran keselamatan:
|
**Langkah 1:** Klik dua kali `picoclaw-launcher`. Anda akan melihat amaran keselamatan:
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/macos-gatekeeper-warning.jpg" alt="Amaran macOS Gatekeeper" width="400">
|
<img src="assets/macos-gatekeeper-warning.jpg" alt="Amaran macOS Gatekeeper" width="400">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> *"picoclaw-launcher" Tidak Dibuka — Apple tidak dapat mengesahkan "picoclaw-launcher" bebas daripada perisian hasad yang mungkin membahayakan Mac anda atau menjejaskan privasi anda.*
|
> *"picoclaw-launcher" Tidak Dibuka — Apple tidak dapat mengesahkan "picoclaw-launcher" bebas daripada perisian hasad yang mungkin membahayakan Mac anda atau menjejaskan privasi anda.*
|
||||||
|
|
@ -287,13 +271,31 @@ macOS mungkin menyekat `picoclaw-launcher` pada pelancaran pertama kerana ia dim
|
||||||
**Langkah 2:** Buka **Tetapan Sistem** → **Privasi & Keselamatan** → tatal ke bawah ke bahagian **Keselamatan** → klik **Buka Juga** → sahkan dengan mengklik **Buka Juga** dalam dialog.
|
**Langkah 2:** Buka **Tetapan Sistem** → **Privasi & Keselamatan** → tatal ke bawah ke bahagian **Keselamatan** → klik **Buka Juga** → sahkan dengan mengklik **Buka Juga** dalam dialog.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/macos-gatekeeper-allow.jpg" alt="macOS Privasi & Keselamatan — Buka Juga" width="600">
|
<img src="assets/macos-gatekeeper-allow.jpg" alt="macOS Privasi & Keselamatan — Buka Juga" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Selepas langkah sekali ini, `picoclaw-launcher` akan dibuka secara normal pada pelancaran seterusnya.
|
Selepas langkah sekali ini, `picoclaw-launcher` akan dibuka secara normal pada pelancaran seterusnya.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### 💻 Pelancar TUI (Disyorkan untuk Headless / SSH)
|
||||||
|
|
||||||
|
Pelancar TUI menyediakan antara muka terminal lengkap untuk konfigurasi dan pengurusan. Sesuai untuk pelayan, Raspberry Pi, dan persekitaran tanpa kepala lain.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw-launcher-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/launcher-tui.jpg" alt="Pelancar TUI" width="600">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Memulakan:**
|
||||||
|
|
||||||
|
Gunakan menu TUI untuk: **1)** Konfigurasikan Penyedia -> **2)** Konfigurasikan Saluran -> **3)** Mulakan Gateway -> **4)** Sembang!
|
||||||
|
|
||||||
|
Untuk dokumentasi TUI terperinci, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||||
|
|
||||||
### 📱 Android
|
### 📱 Android
|
||||||
|
|
||||||
Berikan telefon lama anda kehidupan baru! Jadikannya Pembantu AI pintar dengan PicoClaw.
|
Berikan telefon lama anda kehidupan baru! Jadikannya Pembantu AI pintar dengan PicoClaw.
|
||||||
|
|
@ -304,10 +306,10 @@ Pratonton:
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td><img src="../../assets/fui_main_page.jpg" width="200"></td>
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_web_page.jpg" width="200"></td>
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_log_page.jpg" width="200"></td>
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_setting_page.jpg" width="200"></td>
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
@ -331,7 +333,7 @@ termux-chroot ./picoclaw onboard # chroot menyediakan susun atur sistem fail L
|
||||||
|
|
||||||
Kemudian ikuti bahagian Pelancar Terminal di bawah untuk melengkapkan konfigurasi.
|
Kemudian ikuti bahagian Pelancar Terminal di bawah untuk melengkapkan konfigurasi.
|
||||||
|
|
||||||
<img src="../../assets/termux.jpg" alt="PicoClaw pada Termux" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw pada Termux" width="512">
|
||||||
|
|
||||||
Untuk persekitaran minimal di mana hanya binari teras `picoclaw` tersedia (tiada UI Pelancar), anda boleh mengkonfigurasi semua melalui baris arahan dan fail konfigurasi JSON.
|
Untuk persekitaran minimal di mana hanya binari teras `picoclaw` tersedia (tiada UI Pelancar), anda boleh mengkonfigurasi semua melalui baris arahan dan fail konfigurasi JSON.
|
||||||
|
|
||||||
|
|
@ -439,7 +441,7 @@ PicoClaw menyokong 30+ penyedia LLM melalui konfigurasi `model_list`. Gunakan fo
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Untuk butiran konfigurasi penyedia penuh, lihat [Penyedia & Model](../guides/providers.md).
|
Untuk butiran konfigurasi penyedia penuh, lihat [Penyedia & Model](docs/providers.md).
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -450,28 +452,28 @@ Bercakap dengan PicoClaw anda melalui 17+ platform pemesejan:
|
||||||
|
|
||||||
| Saluran | Persediaan | Protokol | Dok |
|
| Saluran | Persediaan | Protokol | Dok |
|
||||||
|---------|-----------|----------|-----|
|
|---------|-----------|----------|-----|
|
||||||
| **Telegram** | Mudah (token bot) | Long polling | [Panduan](../channels/telegram/README.md) |
|
| **Telegram** | Mudah (token bot) | Long polling | [Panduan](docs/channels/telegram/README.md) |
|
||||||
| **Discord** | Mudah (token bot + intents) | WebSocket | [Panduan](../channels/discord/README.md) |
|
| **Discord** | Mudah (token bot + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) |
|
||||||
| **WhatsApp** | Mudah (imbas QR atau URL jambatan) | Natif / Jambatan | [Panduan](../guides/chat-apps.ms.md#whatsapp) |
|
| **WhatsApp** | Mudah (imbas QR atau URL jambatan) | Natif / Jambatan | [Panduan](docs/chat-apps.md#whatsapp) |
|
||||||
| **Weixin** | Mudah (imbas QR natif) | iLink API | [Panduan](../guides/chat-apps.ms.md#weixin) |
|
| **Weixin** | Mudah (imbas QR natif) | iLink API | [Panduan](docs/chat-apps.md#weixin) |
|
||||||
| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](../channels/qq/README.md) |
|
| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) |
|
||||||
| **Slack** | Mudah (token bot + app) | Socket Mode | [Panduan](../channels/slack/README.md) |
|
| **Slack** | Mudah (token bot + app) | Socket Mode | [Panduan](docs/channels/slack/README.md) |
|
||||||
| **Matrix** | Sederhana (homeserver + token) | Sync API | [Panduan](../channels/matrix/README.md) |
|
| **Matrix** | Sederhana (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) |
|
||||||
| **DingTalk** | Sederhana (kelayakan klien) | Stream | [Panduan](../channels/dingtalk/README.md) |
|
| **DingTalk** | Sederhana (kelayakan klien) | Stream | [Panduan](docs/channels/dingtalk/README.md) |
|
||||||
| **Feishu / Lark** | Sederhana (App ID + Secret) | WebSocket/SDK | [Panduan](../channels/feishu/README.md) |
|
| **Feishu / Lark** | Sederhana (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) |
|
||||||
| **LINE** | Sederhana (kelayakan + webhook) | Webhook | [Panduan](../channels/line/README.md) |
|
| **LINE** | Sederhana (kelayakan + webhook) | Webhook | [Panduan](docs/channels/line/README.md) |
|
||||||
| **WeCom** | Mudah (log masuk QR atau manual) | WebSocket | [Panduan](../channels/wecom/README.md) |
|
| **WeCom** | Mudah (log masuk QR atau manual) | WebSocket | [Panduan](docs/channels/wecom/README.md) |
|
||||||
| **IRC** | Sederhana (pelayan + nick) | Protokol IRC | [Panduan](../guides/chat-apps.ms.md#irc) |
|
| **IRC** | Sederhana (pelayan + nick) | Protokol IRC | [Panduan](docs/chat-apps.md#irc) |
|
||||||
| **OneBot** | Sederhana (URL WebSocket) | OneBot v11 | [Panduan](../channels/onebot/README.md) |
|
| **OneBot** | Sederhana (URL WebSocket) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) |
|
||||||
| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](../channels/maixcam/README.md) |
|
| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) |
|
||||||
| **Pico** | Mudah (aktifkan) | Protokol natif | Terbina dalam |
|
| **Pico** | Mudah (aktifkan) | Protokol natif | Terbina dalam |
|
||||||
| **Pico Client** | Mudah (URL WebSocket) | WebSocket | Terbina dalam |
|
| **Pico Client** | Mudah (URL WebSocket) | WebSocket | Terbina dalam |
|
||||||
|
|
||||||
> Semua saluran berasaskan webhook berkongsi satu pelayan HTTP Gateway (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP yang dikongsi.
|
> Semua saluran berasaskan webhook berkongsi satu pelayan HTTP Gateway (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP yang dikongsi.
|
||||||
|
|
||||||
> Tahap perincian log dikawal oleh `gateway.log_level` (lalai: `warn`). Nilai yang disokong: `debug`, `info`, `warn`, `error`, `fatal`. Boleh juga ditetapkan melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](../guides/configuration.ms.md#gateway-log-level) untuk butiran.
|
> Tahap perincian log dikawal oleh `gateway.log_level` (lalai: `warn`). Nilai yang disokong: `debug`, `info`, `warn`, `error`, `fatal`. Boleh juga ditetapkan melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk butiran.
|
||||||
|
|
||||||
Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](../guides/chat-apps.ms.md).
|
Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](docs/my/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Alat
|
## 🔧 Alat
|
||||||
|
|
||||||
|
|
@ -482,7 +484,7 @@ PicoClaw boleh mencari web untuk menyediakan maklumat terkini. Konfigurasikan da
|
||||||
| Enjin Carian | Kunci API | Peringkat Percuma | Pautan |
|
| Enjin Carian | Kunci API | Peringkat Percuma | Pautan |
|
||||||
|-------------|-----------|-------------------|--------|
|
|-------------|-----------|-------------------|--------|
|
||||||
| DuckDuckGo | Tidak perlu | Tanpa had | Sandaran terbina dalam |
|
| DuckDuckGo | Tidak perlu | Tanpa had | Sandaran terbina dalam |
|
||||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1500 pertanyaan/bulan (peruntukan harian) | Dikuasai AI, dioptimumkan untuk China |
|
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 pertanyaan/hari | Dikuasai AI, dioptimumkan untuk China |
|
||||||
| [Tavily](https://tavily.com) | Diperlukan | 1000 pertanyaan/bulan | Dioptimumkan untuk AI Agent |
|
| [Tavily](https://tavily.com) | Diperlukan | 1000 pertanyaan/bulan | Dioptimumkan untuk AI Agent |
|
||||||
| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 pertanyaan/bulan | Pantas dan peribadi |
|
| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 pertanyaan/bulan | Pantas dan peribadi |
|
||||||
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Carian dikuasai AI |
|
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Carian dikuasai AI |
|
||||||
|
|
@ -491,7 +493,7 @@ PicoClaw boleh mencari web untuk menyediakan maklumat terkini. Konfigurasikan da
|
||||||
|
|
||||||
### ⚙️ Alat Lain
|
### ⚙️ Alat Lain
|
||||||
|
|
||||||
PicoClaw menyertakan alat terbina dalam untuk operasi fail, pelaksanaan kod, penjadualan, dan banyak lagi. Lihat [Konfigurasi Alat](../reference/tools_configuration.md) untuk butiran.
|
PicoClaw menyertakan alat terbina dalam untuk operasi fail, pelaksanaan kod, penjadualan, dan banyak lagi. Lihat [Konfigurasi Alat](docs/tools_configuration.md) untuk butiran.
|
||||||
|
|
||||||
## 🎯 Kemahiran
|
## 🎯 Kemahiran
|
||||||
|
|
||||||
|
|
@ -521,7 +523,7 @@ Tambah ke `config.json` anda:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Untuk butiran lanjut, lihat [Konfigurasi Alat - Kemahiran](../reference/tools_configuration.md#skills-tool).
|
Untuk butiran lanjut, lihat [Konfigurasi Alat - Kemahiran](docs/tools_configuration.md#skills-tool).
|
||||||
|
|
||||||
## 🔗 MCP (Protokol Konteks Model)
|
## 🔗 MCP (Protokol Konteks Model)
|
||||||
|
|
||||||
|
|
@ -544,9 +546,9 @@ PicoClaw menyokong [MCP](https://modelcontextprotocol.io/) secara natif — samb
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Untuk konfigurasi MCP penuh (pengangkutan stdio, SSE, HTTP, Penemuan Alat), lihat [Konfigurasi Alat - MCP](../reference/tools_configuration.md#mcp-tool).
|
Untuk konfigurasi MCP penuh (pengangkutan stdio, SSE, HTTP, Penemuan Alat), lihat [Konfigurasi Alat - MCP](docs/tools_configuration.md#mcp-tool).
|
||||||
|
|
||||||
## <img src="../../assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Sertai Rangkaian Sosial Agent
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Sertai Rangkaian Sosial Agent
|
||||||
|
|
||||||
Sambungkan PicoClaw ke Rangkaian Sosial Agent dengan menghantar satu mesej melalui CLI atau mana-mana Aplikasi Sembang yang disepadukan.
|
Sambungkan PicoClaw ke Rangkaian Sosial Agent dengan menghantar satu mesej melalui CLI atau mana-mana Aplikasi Sembang yang disepadukan.
|
||||||
|
|
||||||
|
|
@ -587,20 +589,20 @@ Untuk panduan terperinci melebihi README ini:
|
||||||
|
|
||||||
| Topik | Penerangan |
|
| Topik | Penerangan |
|
||||||
|-------|------------|
|
|-------|------------|
|
||||||
| [Docker & Permulaan Pantas](../guides/docker.ms.md) | Persediaan Docker Compose, mod Launcher/Agent |
|
| [Docker & Permulaan Pantas](docs/my/docker.md) | Persediaan Docker Compose, mod Launcher/Agent |
|
||||||
| [Aplikasi Sembang](../guides/chat-apps.ms.md) | Panduan persediaan 17+ saluran |
|
| [Aplikasi Sembang](docs/my/chat-apps.md) | Panduan persediaan 17+ saluran |
|
||||||
| [Konfigurasi](../guides/configuration.ms.md) | Pemboleh ubah persekitaran, susun atur ruang kerja |
|
| [Konfigurasi](docs/my/configuration.md) | Pemboleh ubah persekitaran, susun atur ruang kerja |
|
||||||
| [Penyedia & Model](../guides/providers.md) | 30+ penyedia LLM, penghalaan model |
|
| [Penyedia & Model](docs/providers.md) | 30+ penyedia LLM, penghalaan model |
|
||||||
| [Spawn & Tugasan Async](../guides/spawn-tasks.ms.md) | Tugasan pantas, tugasan panjang dengan spawn |
|
| [Spawn & Tugasan Async](docs/my/spawn-tasks.md) | Tugasan pantas, tugasan panjang dengan spawn |
|
||||||
| [Penyelesaian Masalah](../operations/troubleshooting.ms.md) | Isu biasa dan penyelesaian |
|
| [Penyelesaian Masalah](docs/my/troubleshooting.md) | Isu biasa dan penyelesaian |
|
||||||
| [Konfigurasi Alat](../reference/tools_configuration.md) | Aktif/nyahaktif alat, dasar exec, MCP, Kemahiran |
|
| [Konfigurasi Alat](docs/tools_configuration.md) | Aktif/nyahaktif alat, dasar exec, MCP, Kemahiran |
|
||||||
| [Keserasian Perkakasan](../guides/hardware-compatibility.md) | Papan yang diuji, keperluan minimum |
|
| [Keserasian Perkakasan](docs/hardware-compatibility.md) | Papan yang diuji, keperluan minimum |
|
||||||
|
|
||||||
## 🤝 Sumbangan & Peta Jalan
|
## 🤝 Sumbangan & Peta Jalan
|
||||||
|
|
||||||
PR dialu-alukan! Kod sumber sengaja dibuat kecil dan mudah dibaca.
|
PR dialu-alukan! Kod sumber sengaja dibuat kecil dan mudah dibaca.
|
||||||
|
|
||||||
Lihat [Peta Jalan Komuniti](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](../../CONTRIBUTING.md) untuk panduan.
|
Lihat [Peta Jalan Komuniti](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](CONTRIBUTING.md) untuk panduan.
|
||||||
|
|
||||||
Kumpulan pembangun sedang dibina, sertai selepas PR pertama anda digabungkan!
|
Kumpulan pembangun sedang dibina, sertai selepas PR pertama anda digabungkan!
|
||||||
|
|
||||||
|
|
@ -609,4 +611,4 @@ Kumpulan Pengguna:
|
||||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
WeChat:
|
WeChat:
|
||||||
<img src="../../assets/wechat.png" alt="Kod QR kumpulan WeChat" width="512">
|
<img src="assets/wechat.png" alt="Kod QR kumpulan WeChat" width="512">
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="../../assets/logo.webp" 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>
|
||||||
|
|
||||||
|
|
@ -14,11 +14,11 @@
|
||||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||||
<br>
|
<br>
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
<a href="../../assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -34,12 +34,12 @@
|
||||||
<tr align="center">
|
<tr align="center">
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/picoclaw_mem.gif" width="360" height="240">
|
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/licheervnano.png" width="400" height="240">
|
<img src="assets/licheervnano.png" width="400" height="240">
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -56,14 +56,6 @@
|
||||||
|
|
||||||
## 📢 Novidades
|
## 📢 Novidades
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw no AliExpress!** Agora você pode comprar o LicheeRV-Claw no [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), facilitando testar o PicoClaw em hardware RISC-V compacto.
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
|
||||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
2026-03-31 📱 **Suporte Android!** PicoClaw agora roda no Android! Baixe o APK em [picoclaw.io](https://picoclaw.io/download)
|
2026-03-31 📱 **Suporte Android!** PicoClaw agora roda no Android! Baixe o APK em [picoclaw.io](https://picoclaw.io/download)
|
||||||
|
|
||||||
2026-03-25 🚀 **v0.2.4 Lançada!** Reformulação da arquitetura Agent (SubTurn, Hooks, Steering, EventBus), integração WeChat/WeCom, fortalecimento de segurança (.security.yml, filtragem de dados sensíveis), novos providers (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correções de bugs. O PicoClaw atingiu **26K Stars**!
|
2026-03-25 🚀 **v0.2.4 Lançada!** Reformulação da arquitetura Agent (SubTurn, Hooks, Steering, EventBus), integração WeChat/WeCom, fortalecimento de segurança (.security.yml, filtragem de dados sensíveis), novos providers (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correções de bugs. O PicoClaw atingiu **26K Stars**!
|
||||||
|
|
@ -79,7 +71,7 @@
|
||||||
|
|
||||||
2026-02-26 🎉 O PicoClaw atinge **20K Stars** em apenas 17 dias! Orquestração automática de channels e interfaces de capacidade estão disponíveis.
|
2026-02-26 🎉 O PicoClaw atinge **20K Stars** em apenas 17 dias! Orquestração automática de channels e interfaces de capacidade estão disponíveis.
|
||||||
|
|
||||||
2026-02-16 🎉 O PicoClaw ultrapassa 12K Stars em uma semana! Funções de mantenedor da comunidade e [Roadmap](../../ROADMAP.md) lançados oficialmente.
|
2026-02-16 🎉 O PicoClaw ultrapassa 12K Stars em uma semana! Funções de mantenedor da comunidade e [Roadmap](ROADMAP.md) lançados oficialmente.
|
||||||
|
|
||||||
2026-02-13 🎉 O PicoClaw ultrapassa 5000 Stars em 4 dias! Roadmap do projeto e grupos de desenvolvedores em andamento.
|
2026-02-13 🎉 O PicoClaw ultrapassa 5000 Stars em 4 dias! Roadmap do projeto e grupos de desenvolvedores em andamento.
|
||||||
|
|
||||||
|
|
@ -116,14 +108,14 @@ _*Builds recentes podem usar 10-20MB devido a merges rápidos de PRs. Otimizaç
|
||||||
| **Tempo de boot**</br>(core 0,8GHz) | >500s | >30s | **<1s** |
|
| **Tempo de boot**</br>(core 0,8GHz) | >500s | >30s | **<1s** |
|
||||||
| **Custo** | Mac Mini $599 | Maioria das placas Linux ~$50 | **Qualquer placa Linux**</br>**a partir de $10** |
|
| **Custo** | Mac Mini $599 | Maioria das placas Linux ~$50 | **Qualquer placa Linux**</br>**a partir de $10** |
|
||||||
|
|
||||||
<img src="../../assets/compare.jpg" alt="PicoClaw" width="512">
|
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **[Lista de Compatibilidade de Hardware](../guides/hardware-compatibility.pt-br.md)** — Veja todas as placas testadas, de RISC-V de $5 ao Raspberry Pi e celulares Android. Sua placa não está listada? Envie um PR!
|
> **[Lista de Compatibilidade de Hardware](docs/pt-br/hardware-compatibility.md)** — Veja todas as placas testadas, de RISC-V de $5 ao Raspberry Pi e celulares Android. Sua placa não está listada? Envie um PR!
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 🦾 Demonstração
|
## 🦾 Demonstração
|
||||||
|
|
@ -137,9 +129,9 @@ _*Builds recentes podem usar 10-20MB devido a merges rápidos de PRs. Otimizaç
|
||||||
<th><p align="center">Busca na Web e Aprendizado</p></th>
|
<th><p align="center">Busca na Web e Aprendizado</p></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">Desenvolver · Implantar · Escalar</td>
|
<td align="center">Desenvolver · Implantar · Escalar</td>
|
||||||
|
|
@ -172,27 +164,19 @@ Alternativamente, baixe o binário para sua plataforma na página de [GitHub Rel
|
||||||
|
|
||||||
### Compilar a partir do código-fonte (para desenvolvimento)
|
### Compilar a partir do código-fonte (para desenvolvimento)
|
||||||
|
|
||||||
Pré-requisitos:
|
|
||||||
|
|
||||||
- Go 1.25+
|
|
||||||
- Node.js 22+ e pnpm 10.33.0+ para builds do Web UI / launcher
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Instalar dependências do frontend
|
|
||||||
(cd web/frontend && pnpm install --frozen-lockfile)
|
|
||||||
|
|
||||||
# Compilar o binário principal
|
# Compilar o binário principal
|
||||||
make build
|
make build
|
||||||
|
|
||||||
# Compilar o Web UI Launcher (necessário para o modo WebUI)
|
# Compilar o Web UI Launcher (necessário para o modo WebUI)
|
||||||
make build-launcher
|
make build-launcher
|
||||||
|
|
||||||
# Compilar os binários core para todas as plataformas gerenciadas pelo Makefile
|
# Compilar para múltiplas plataformas
|
||||||
make build-all
|
make build-all
|
||||||
|
|
||||||
# Compilar para Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
# Compilar para Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||||
|
|
@ -228,7 +212,7 @@ picoclaw-launcher
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Primeiros passos:**
|
**Primeiros passos:**
|
||||||
|
|
@ -282,7 +266,7 @@ O macOS pode bloquear o `picoclaw-launcher` no primeiro lançamento porque ele f
|
||||||
**Passo 1:** Dê um duplo clique em `picoclaw-launcher`. Você verá um aviso de segurança:
|
**Passo 1:** Dê um duplo clique em `picoclaw-launcher`. Você verá um aviso de segurança:
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/macos-gatekeeper-warning.jpg" alt="Aviso do macOS Gatekeeper" width="400">
|
<img src="assets/macos-gatekeeper-warning.jpg" alt="Aviso do macOS Gatekeeper" width="400">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> *"picoclaw-launcher" não foi aberto — A Apple não conseguiu verificar se "picoclaw-launcher" está livre de malware que possa prejudicar seu Mac ou comprometer sua privacidade.*
|
> *"picoclaw-launcher" não foi aberto — A Apple não conseguiu verificar se "picoclaw-launcher" está livre de malware que possa prejudicar seu Mac ou comprometer sua privacidade.*
|
||||||
|
|
@ -290,14 +274,31 @@ O macOS pode bloquear o `picoclaw-launcher` no primeiro lançamento porque ele f
|
||||||
**Passo 2:** Abra **Configurações do Sistema** → **Privacidade e Segurança** → role até a seção **Segurança** → clique em **Abrir Mesmo Assim** → confirme clicando em **Abrir Mesmo Assim** na caixa de diálogo.
|
**Passo 2:** Abra **Configurações do Sistema** → **Privacidade e Segurança** → role até a seção **Segurança** → clique em **Abrir Mesmo Assim** → confirme clicando em **Abrir Mesmo Assim** na caixa de diálogo.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/macos-gatekeeper-allow.jpg" alt="macOS Privacidade e Segurança — Abrir Mesmo Assim" width="600">
|
<img src="assets/macos-gatekeeper-allow.jpg" alt="macOS Privacidade e Segurança — Abrir Mesmo Assim" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Após esta etapa única, o `picoclaw-launcher` abrirá normalmente nos lançamentos seguintes.
|
Após esta etapa única, o `picoclaw-launcher` abrirá normalmente nos lançamentos seguintes.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<a id="-run-on-old-android-phones"></a>
|
### 💻 TUI Launcher (Recomendado para Headless / SSH)
|
||||||
|
|
||||||
|
O TUI (Terminal UI) Launcher fornece uma interface de terminal completa para configuração e gerenciamento. Ideal para servidores, Raspberry Pi e outros ambientes headless.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw-launcher-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Primeiros passos:**
|
||||||
|
|
||||||
|
Use os menus do TUI para: **1)** Configurar um Provider -> **2)** Configurar um Channel -> **3)** Iniciar o Gateway -> **4)** Conversar!
|
||||||
|
|
||||||
|
Para documentação detalhada do TUI, veja [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||||
|
|
||||||
### 📱 Android
|
### 📱 Android
|
||||||
|
|
||||||
Dê uma segunda vida ao seu celular de uma década! Transforme-o em um Assistente de IA inteligente com o PicoClaw.
|
Dê uma segunda vida ao seu celular de uma década! Transforme-o em um Assistente de IA inteligente com o PicoClaw.
|
||||||
|
|
@ -308,10 +309,10 @@ Pré-visualização:
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td><img src="../../assets/fui_main_page.jpg" width="200"></td>
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_web_page.jpg" width="200"></td>
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_log_page.jpg" width="200"></td>
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_setting_page.jpg" width="200"></td>
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
@ -335,7 +336,7 @@ termux-chroot ./picoclaw onboard # chroot fornece um layout padrão de sistema
|
||||||
|
|
||||||
Em seguida, siga a seção Terminal Launcher abaixo para concluir a configuração.
|
Em seguida, siga a seção Terminal Launcher abaixo para concluir a configuração.
|
||||||
|
|
||||||
<img src="../../assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||||
|
|
||||||
Para ambientes mínimos onde apenas o binário principal `picoclaw` está disponível (sem Launcher UI), você pode configurar tudo via linha de comando e um arquivo de configuração JSON.
|
Para ambientes mínimos onde apenas o binário principal `picoclaw` está disponível (sem Launcher UI), você pode configurar tudo via linha de comando e um arquivo de configuração JSON.
|
||||||
|
|
||||||
|
|
@ -351,7 +352,6 @@ Isso cria `~/.picoclaw/config.json` e o diretório workspace.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 3,
|
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model_name": "gpt-5.4"
|
"model_name": "gpt-5.4"
|
||||||
|
|
@ -361,7 +361,7 @@ Isso cria `~/.picoclaw/config.json` e o diretório workspace.
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_keys": ["sk-your-api-key"]
|
"api_key": "sk-your-api-key"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -442,7 +442,7 @@ O PicoClaw suporta mais de 30 providers de LLM através da configuração `model
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Para detalhes completos de configuração de providers, veja [Providers & Models](../guides/providers.pt-br.md).
|
Para detalhes completos de configuração de providers, veja [Providers & Models](docs/pt-br/providers.md).
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -452,28 +452,28 @@ Converse com seu PicoClaw por meio de mais de 17 plataformas de mensagens:
|
||||||
|
|
||||||
| Channel | Configuração | Protocolo | Docs |
|
| Channel | Configuração | Protocolo | Docs |
|
||||||
|---------|--------------|-----------|------|
|
|---------|--------------|-----------|------|
|
||||||
| **Telegram** | Fácil (bot token) | Long polling | [Guia](../channels/telegram/README.pt-br.md) |
|
| **Telegram** | Fácil (bot token) | Long polling | [Guia](docs/channels/telegram/README.pt-br.md) |
|
||||||
| **Discord** | Fácil (bot token + intents) | WebSocket | [Guia](../channels/discord/README.pt-br.md) |
|
| **Discord** | Fácil (bot token + intents) | WebSocket | [Guia](docs/channels/discord/README.pt-br.md) |
|
||||||
| **WhatsApp** | Fácil (QR scan ou bridge URL) | Nativo / Bridge | [Guia](../guides/chat-apps.pt-br.md#whatsapp) |
|
| **WhatsApp** | Fácil (QR scan ou bridge URL) | Nativo / Bridge | [Guia](docs/pt-br/chat-apps.md#whatsapp) |
|
||||||
| **Weixin** | Fácil (scan QR nativo) | iLink API | [Guia](../guides/chat-apps.pt-br.md#weixin) |
|
| **Weixin** | Fácil (scan QR nativo) | iLink API | [Guia](docs/pt-br/chat-apps.md#weixin) |
|
||||||
| **QQ** | Fácil (AppID + AppSecret) | WebSocket | [Guia](../channels/qq/README.pt-br.md) |
|
| **QQ** | Fácil (AppID + AppSecret) | WebSocket | [Guia](docs/channels/qq/README.pt-br.md) |
|
||||||
| **Slack** | Fácil (bot + app token) | Socket Mode | [Guia](../channels/slack/README.pt-br.md) |
|
| **Slack** | Fácil (bot + app token) | Socket Mode | [Guia](docs/channels/slack/README.pt-br.md) |
|
||||||
| **Matrix** | Médio (homeserver + token) | Sync API | [Guia](../channels/matrix/README.pt-br.md) |
|
| **Matrix** | Médio (homeserver + token) | Sync API | [Guia](docs/channels/matrix/README.pt-br.md) |
|
||||||
| **DingTalk** | Médio (credenciais do cliente) | Stream | [Guia](../channels/dingtalk/README.pt-br.md) |
|
| **DingTalk** | Médio (credenciais do cliente) | Stream | [Guia](docs/channels/dingtalk/README.pt-br.md) |
|
||||||
| **Feishu / Lark** | Médio (App ID + Secret) | WebSocket/SDK | [Guia](../channels/feishu/README.pt-br.md) |
|
| **Feishu / Lark** | Médio (App ID + Secret) | WebSocket/SDK | [Guia](docs/channels/feishu/README.pt-br.md) |
|
||||||
| **LINE** | Médio (credenciais + webhook) | Webhook | [Guia](../channels/line/README.pt-br.md) |
|
| **LINE** | Médio (credenciais + webhook) | Webhook | [Guia](docs/channels/line/README.pt-br.md) |
|
||||||
| **WeCom** | Fácil (login QR ou manual) | WebSocket | [Guia](../channels/wecom/README.pt-br.md) |
|
| **WeCom** | Fácil (login QR ou manual) | WebSocket | [Guia](docs/channels/wecom/README.md) |
|
||||||
| **IRC** | Médio (servidor + nick) | Protocolo IRC | [Guia](../guides/chat-apps.pt-br.md#irc) |
|
| **IRC** | Médio (servidor + nick) | Protocolo IRC | [Guia](docs/pt-br/chat-apps.md#irc) |
|
||||||
| **OneBot** | Médio (WebSocket URL) | OneBot v11 | [Guia](../channels/onebot/README.pt-br.md) |
|
| **OneBot** | Médio (WebSocket URL) | OneBot v11 | [Guia](docs/channels/onebot/README.pt-br.md) |
|
||||||
| **MaixCam** | Fácil (habilitar) | TCP socket | [Guia](../channels/maixcam/README.pt-br.md) |
|
| **MaixCam** | Fácil (habilitar) | TCP socket | [Guia](docs/channels/maixcam/README.pt-br.md) |
|
||||||
| **Pico** | Fácil (habilitar) | Protocolo nativo | Integrado |
|
| **Pico** | Fácil (habilitar) | Protocolo nativo | Integrado |
|
||||||
| **Pico Client** | Fácil (WebSocket URL) | WebSocket | Integrado |
|
| **Pico Client** | Fácil (WebSocket URL) | WebSocket | Integrado |
|
||||||
|
|
||||||
> Todos os channels baseados em webhook compartilham um único servidor HTTP do Gateway (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). O Feishu usa modo WebSocket/SDK e não utiliza o servidor HTTP compartilhado.
|
> Todos os channels baseados em webhook compartilham um único servidor HTTP do Gateway (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). O Feishu usa modo WebSocket/SDK e não utiliza o servidor HTTP compartilhado.
|
||||||
|
|
||||||
> A verbosidade dos logs é controlada por `gateway.log_level` (padrão: `warn`). Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`. Também pode ser definido via `PICOCLAW_LOG_LEVEL`. Veja [Configuração](../guides/configuration.pt-br.md#nível-de-log-do-gateway) para detalhes.
|
> A verbosidade dos logs é controlada por `gateway.log_level` (padrão: `warn`). Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`. Também pode ser definido via `PICOCLAW_LOG_LEVEL`. Veja [Configuração](docs/pt-br/configuration.md#nível-de-log-do-gateway) para detalhes.
|
||||||
|
|
||||||
Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](../guides/chat-apps.pt-br.md).
|
Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](docs/pt-br/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Ferramentas
|
## 🔧 Ferramentas
|
||||||
|
|
||||||
|
|
@ -484,7 +484,7 @@ O PicoClaw pode pesquisar na web para fornecer informações atualizadas. Config
|
||||||
| Motor de Busca | API Key | Nível Gratuito | Link |
|
| Motor de Busca | API Key | Nível Gratuito | Link |
|
||||||
|----------------|---------|----------------|------|
|
|----------------|---------|----------------|------|
|
||||||
| DuckDuckGo | Não necessária | Ilimitado | Fallback integrado |
|
| DuckDuckGo | Não necessária | Ilimitado | Fallback integrado |
|
||||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Obrigatória | 1500 consultas/mês (alocação diária) | IA, otimizado para chinês |
|
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Obrigatória | 1000 consultas/dia | IA, otimizado para chinês |
|
||||||
| [Tavily](https://tavily.com) | Obrigatória | 1000 consultas/mês | Otimizado para AI Agents |
|
| [Tavily](https://tavily.com) | Obrigatória | 1000 consultas/mês | Otimizado para AI Agents |
|
||||||
| [Brave Search](https://brave.com/search/api) | Obrigatória | 2000 consultas/mês | Rápido e privado |
|
| [Brave Search](https://brave.com/search/api) | Obrigatória | 2000 consultas/mês | Rápido e privado |
|
||||||
| [Perplexity](https://www.perplexity.ai) | Obrigatória | Pago | Busca com IA |
|
| [Perplexity](https://www.perplexity.ai) | Obrigatória | Pago | Busca com IA |
|
||||||
|
|
@ -493,7 +493,7 @@ O PicoClaw pode pesquisar na web para fornecer informações atualizadas. Config
|
||||||
|
|
||||||
### ⚙️ Outras Ferramentas
|
### ⚙️ Outras Ferramentas
|
||||||
|
|
||||||
O PicoClaw inclui ferramentas integradas para operações de arquivo, execução de código, agendamento e mais. Veja [Configuração de Ferramentas](../reference/tools_configuration.pt-br.md) para detalhes.
|
O PicoClaw inclui ferramentas integradas para operações de arquivo, execução de código, agendamento e mais. Veja [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) para detalhes.
|
||||||
|
|
||||||
## 🎯 Skills
|
## 🎯 Skills
|
||||||
|
|
||||||
|
|
@ -523,7 +523,7 @@ Adicione ao seu `config.json`:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Para mais detalhes, veja [Configuração de Ferramentas - Skills](../reference/tools_configuration.pt-br.md#skills-tool).
|
Para mais detalhes, veja [Configuração de Ferramentas - Skills](docs/pt-br/tools_configuration.md#skills-tool).
|
||||||
|
|
||||||
## 🔗 MCP (Model Context Protocol)
|
## 🔗 MCP (Model Context Protocol)
|
||||||
|
|
||||||
|
|
@ -546,9 +546,9 @@ O PicoClaw suporta nativamente o [MCP](https://modelcontextprotocol.io/) — con
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Para configuração completa de MCP (transportes stdio, SSE, HTTP, Tool Discovery), veja [Configuração de Ferramentas - MCP](../reference/tools_configuration.pt-br.md#mcp-tool).
|
Para configuração completa de MCP (transportes stdio, SSE, HTTP, Tool Discovery), veja [Configuração de Ferramentas - MCP](docs/pt-br/tools_configuration.md#mcp-tool).
|
||||||
|
|
||||||
## <img src="../../assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Junte-se à Rede Social de Agents
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Junte-se à Rede Social de Agents
|
||||||
|
|
||||||
Conecte o PicoClaw à Rede Social de Agents simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado.
|
Conecte o PicoClaw à Rede Social de Agents simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado.
|
||||||
|
|
||||||
|
|
@ -589,23 +589,23 @@ Para guias detalhados além deste README:
|
||||||
|
|
||||||
| Tópico | Descrição |
|
| Tópico | Descrição |
|
||||||
|--------|-----------|
|
|--------|-----------|
|
||||||
| [Docker & Início Rápido](../guides/docker.pt-br.md) | Configuração do Docker Compose, modos Launcher/Agent |
|
| [Docker & Início Rápido](docs/pt-br/docker.md) | Configuração do Docker Compose, modos Launcher/Agent |
|
||||||
| [Apps de Chat](../guides/chat-apps.pt-br.md) | Guias de configuração para todos os 17+ channels |
|
| [Apps de Chat](docs/pt-br/chat-apps.md) | Guias de configuração para todos os 17+ channels |
|
||||||
| [Configuração](../guides/configuration.pt-br.md) | Variáveis de ambiente, layout do workspace, sandbox de segurança |
|
| [Configuração](docs/pt-br/configuration.md) | Variáveis de ambiente, layout do workspace, sandbox de segurança |
|
||||||
| [Providers & Models](../guides/providers.pt-br.md) | 30+ providers de LLM, roteamento de modelos, configuração de model_list |
|
| [Providers & Models](docs/pt-br/providers.md) | 30+ providers de LLM, roteamento de modelos, configuração de model_list |
|
||||||
| [Spawn & Tarefas Assíncronas](../guides/spawn-tasks.pt-br.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agents |
|
| [Spawn & Tarefas Assíncronas](docs/pt-br/spawn-tasks.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agents |
|
||||||
| [Hooks](../architecture/hooks/README.md) | Sistema de hooks orientado a eventos: observadores, interceptores, hooks de aprovação |
|
| [Hooks](docs/hooks/README.md) | Sistema de hooks orientado a eventos: observadores, interceptores, hooks de aprovação |
|
||||||
| [Steering](../architecture/steering.md) | Injetar mensagens em um loop de agente em execução |
|
| [Steering](docs/steering.md) | Injetar mensagens em um loop de agente em execução |
|
||||||
| [SubTurn](../architecture/subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida |
|
| [SubTurn](docs/subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida |
|
||||||
| [Solução de Problemas](../operations/troubleshooting.pt-br.md) | Problemas comuns e soluções |
|
| [Solução de Problemas](docs/pt-br/troubleshooting.md) | Problemas comuns e soluções |
|
||||||
| [Configuração de Ferramentas](../reference/tools_configuration.pt-br.md) | Habilitar/desabilitar por ferramenta, políticas de exec, MCP, Skills |
|
| [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) | Habilitar/desabilitar por ferramenta, políticas de exec, MCP, Skills |
|
||||||
| [Compatibilidade de Hardware](../guides/hardware-compatibility.pt-br.md) | Placas testadas, requisitos mínimos |
|
| [Compatibilidade de Hardware](docs/pt-br/hardware-compatibility.md) | Placas testadas, requisitos mínimos |
|
||||||
|
|
||||||
## 🤝 Contribuir & Roadmap
|
## 🤝 Contribuir & Roadmap
|
||||||
|
|
||||||
PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível.
|
PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível.
|
||||||
|
|
||||||
Veja nosso [Roadmap da Comunidade](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](../../CONTRIBUTING.md) para diretrizes.
|
Veja nosso [Roadmap da Comunidade](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](CONTRIBUTING.md) para diretrizes.
|
||||||
|
|
||||||
Grupo de desenvolvedores em formação, entre após seu primeiro PR mesclado!
|
Grupo de desenvolvedores em formação, entre após seu primeiro PR mesclado!
|
||||||
|
|
||||||
|
|
@ -614,4 +614,4 @@ Grupos de Usuários:
|
||||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
WeChat:
|
WeChat:
|
||||||
<img src="../../assets/wechat.png" alt="WeChat group QR code" width="512">
|
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="../../assets/logo.webp" 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>
|
||||||
|
|
||||||
|
|
@ -14,11 +14,11 @@
|
||||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||||
<br>
|
<br>
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
<a href="../../assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -34,12 +34,12 @@
|
||||||
<tr align="center">
|
<tr align="center">
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/picoclaw_mem.gif" width="360" height="240">
|
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/licheervnano.png" width="400" height="240">
|
<img src="assets/licheervnano.png" width="400" height="240">
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -56,14 +56,6 @@
|
||||||
|
|
||||||
## 📢 Tin tức
|
## 📢 Tin tức
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw đã có trên AliExpress!** Bạn hiện có thể mua LicheeRV-Claw trên [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), giúp việc thử PicoClaw trên phần cứng RISC-V nhỏ gọn dễ dàng hơn.
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
|
||||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
2026-03-31 📱 **Hỗ trợ Android!** PicoClaw giờ chạy trên Android! Tải APK tại [picoclaw.io](https://picoclaw.io/download)
|
2026-03-31 📱 **Hỗ trợ Android!** PicoClaw giờ chạy trên Android! Tải APK tại [picoclaw.io](https://picoclaw.io/download)
|
||||||
|
|
||||||
2026-03-25 🚀 **v0.2.4 đã phát hành!** Tái cấu trúc kiến trúc Agent (SubTurn, Hooks, Steering, EventBus), tích hợp WeChat/WeCom, tăng cường bảo mật (.security.yml, lọc dữ liệu nhạy cảm), provider mới (AWS Bedrock, Azure, Xiaomi MiMo) và 35 bản vá lỗi. PicoClaw đã đạt **26K Stars**!
|
2026-03-25 🚀 **v0.2.4 đã phát hành!** Tái cấu trúc kiến trúc Agent (SubTurn, Hooks, Steering, EventBus), tích hợp WeChat/WeCom, tăng cường bảo mật (.security.yml, lọc dữ liệu nhạy cảm), provider mới (AWS Bedrock, Azure, Xiaomi MiMo) và 35 bản vá lỗi. PicoClaw đã đạt **26K Stars**!
|
||||||
|
|
@ -79,7 +71,7 @@
|
||||||
|
|
||||||
2026-02-26 🎉 PicoClaw đạt **20K Stars** chỉ trong 17 ngày! Tự động điều phối Channel và giao diện khả năng đã hoạt động.
|
2026-02-26 🎉 PicoClaw đạt **20K Stars** chỉ trong 17 ngày! Tự động điều phối Channel và giao diện khả năng đã hoạt động.
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw vượt 12K Stars trong một tuần! Vai trò người duy trì cộng đồng và [Lộ trình](../../ROADMAP.md) chính thức ra mắt.
|
2026-02-16 🎉 PicoClaw vượt 12K Stars trong một tuần! Vai trò người duy trì cộng đồng và [Lộ trình](ROADMAP.md) chính thức ra mắt.
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw vượt 5000 Stars trong 4 ngày! Lộ trình dự án và nhóm nhà phát triển đang được xây dựng.
|
2026-02-13 🎉 PicoClaw vượt 5000 Stars trong 4 ngày! Lộ trình dự án và nhóm nhà phát triển đang được xây dựng.
|
||||||
|
|
||||||
|
|
@ -116,14 +108,14 @@ _*Các bản build gần đây có thể dùng 10-20MB do merge PR nhanh. Tối
|
||||||
| **Thời gian khởi động**</br>(lõi 0.8GHz) | >500s | >30s | **<1s** |
|
| **Thời gian khởi động**</br>(lõi 0.8GHz) | >500s | >30s | **<1s** |
|
||||||
| **Chi phí** | Mac Mini $599 | Hầu hết board Linux ~$50 | **Bất kỳ board Linux**</br>**từ $10** |
|
| **Chi phí** | Mac Mini $599 | Hầu hết board Linux ~$50 | **Bất kỳ board Linux**</br>**từ $10** |
|
||||||
|
|
||||||
<img src="../../assets/compare.jpg" alt="PicoClaw" width="512">
|
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **[Danh sách Tương thích Phần cứng](../guides/hardware-compatibility.vi.md)** — Xem tất cả các board đã được kiểm tra, từ RISC-V $5 đến Raspberry Pi đến điện thoại Android. Board của bạn chưa có trong danh sách? Gửi PR!
|
> **[Danh sách Tương thích Phần cứng](docs/vi/hardware-compatibility.md)** — Xem tất cả các board đã được kiểm tra, từ RISC-V $5 đến Raspberry Pi đến điện thoại Android. Board của bạn chưa có trong danh sách? Gửi PR!
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 🦾 Minh họa
|
## 🦾 Minh họa
|
||||||
|
|
@ -137,9 +129,9 @@ _*Các bản build gần đây có thể dùng 10-20MB do merge PR nhanh. Tối
|
||||||
<th><p align="center">Tìm kiếm Web & Học tập</p></th>
|
<th><p align="center">Tìm kiếm Web & Học tập</p></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">Phát triển · Triển khai · Mở rộng</td>
|
<td align="center">Phát triển · Triển khai · Mở rộng</td>
|
||||||
|
|
@ -172,27 +164,19 @@ Ngoài ra, tải binary cho nền tảng của bạn từ trang [GitHub Releases
|
||||||
|
|
||||||
### Xây dựng từ mã nguồn (để phát triển)
|
### Xây dựng từ mã nguồn (để phát triển)
|
||||||
|
|
||||||
Yêu cầu:
|
|
||||||
|
|
||||||
- Go 1.25+
|
|
||||||
- Node.js 22+ và pnpm 10.33.0+ cho các bản build Web UI / launcher
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Cài đặt dependencies frontend
|
# Build core binary
|
||||||
(cd web/frontend && pnpm install --frozen-lockfile)
|
|
||||||
|
|
||||||
# Build binary lõi
|
|
||||||
make build
|
make build
|
||||||
|
|
||||||
# Build Web UI Launcher (cần cho chế độ WebUI)
|
# Build Web UI Launcher (required for WebUI mode)
|
||||||
make build-launcher
|
make build-launcher
|
||||||
|
|
||||||
# Build các binary lõi cho mọi nền tảng do Makefile quản lý
|
# 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)
|
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||||
|
|
@ -228,7 +212,7 @@ picoclaw-launcher
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Bắt đầu:**
|
**Bắt đầu:**
|
||||||
|
|
@ -282,7 +266,7 @@ macOS có thể chặn `picoclaw-launcher` khi khởi chạy lần đầu vì n
|
||||||
**Bước 1:** Nhấp đúp vào `picoclaw-launcher`. Bạn sẽ thấy cảnh báo bảo mật:
|
**Bước 1:** Nhấp đúp vào `picoclaw-launcher`. Bạn sẽ thấy cảnh báo bảo mật:
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/macos-gatekeeper-warning.jpg" alt="Cảnh báo macOS Gatekeeper" width="400">
|
<img src="assets/macos-gatekeeper-warning.jpg" alt="Cảnh báo macOS Gatekeeper" width="400">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> *"picoclaw-launcher" Không Mở Được — Apple không thể xác minh "picoclaw-launcher" không chứa phần mềm độc hại có thể gây hại cho Mac hoặc xâm phạm quyền riêng tư của bạn.*
|
> *"picoclaw-launcher" Không Mở Được — Apple không thể xác minh "picoclaw-launcher" không chứa phần mềm độc hại có thể gây hại cho Mac hoặc xâm phạm quyền riêng tư của bạn.*
|
||||||
|
|
@ -290,14 +274,31 @@ macOS có thể chặn `picoclaw-launcher` khi khởi chạy lần đầu vì n
|
||||||
**Bước 2:** Mở **Cài đặt Hệ thống** → **Quyền riêng tư & Bảo mật** → cuộn xuống phần **Bảo mật** → nhấp **Vẫn Mở** → xác nhận bằng cách nhấp **Vẫn Mở** trong hộp thoại.
|
**Bước 2:** Mở **Cài đặt Hệ thống** → **Quyền riêng tư & Bảo mật** → cuộn xuống phần **Bảo mật** → nhấp **Vẫn Mở** → xác nhận bằng cách nhấp **Vẫn Mở** trong hộp thoại.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/macos-gatekeeper-allow.jpg" alt="macOS Quyền riêng tư & Bảo mật — Vẫn Mở" width="600">
|
<img src="assets/macos-gatekeeper-allow.jpg" alt="macOS Quyền riêng tư & Bảo mật — Vẫn Mở" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Sau bước này, `picoclaw-launcher` sẽ mở bình thường trong các lần khởi chạy tiếp theo.
|
Sau bước này, `picoclaw-launcher` sẽ mở bình thường trong các lần khởi chạy tiếp theo.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<a id="-run-on-old-android-phones"></a>
|
### 💻 TUI Launcher (Khuyến nghị cho Headless / SSH)
|
||||||
|
|
||||||
|
TUI (Terminal UI) Launcher cung cấp giao diện terminal đầy đủ tính năng để cấu hình và quản lý. Lý tưởng cho máy chủ, Raspberry Pi và các môi trường headless khác.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw-launcher-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Bắt đầu:**
|
||||||
|
|
||||||
|
Sử dụng menu TUI để: **1)** Cấu hình Provider -> **2)** Cấu hình Channel -> **3)** Khởi động Gateway -> **4)** Trò chuyện!
|
||||||
|
|
||||||
|
Để biết tài liệu TUI chi tiết, xem [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||||
|
|
||||||
### 📱 Android
|
### 📱 Android
|
||||||
|
|
||||||
Hãy cho chiếc điện thoại cũ của bạn một cuộc sống mới! Biến nó thành Trợ lý AI thông minh với PicoClaw.
|
Hãy cho chiếc điện thoại cũ của bạn một cuộc sống mới! Biến nó thành Trợ lý AI thông minh với PicoClaw.
|
||||||
|
|
@ -308,10 +309,10 @@ Xem trước:
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td><img src="../../assets/fui_main_page.jpg" width="200"></td>
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_web_page.jpg" width="200"></td>
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_log_page.jpg" width="200"></td>
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_setting_page.jpg" width="200"></td>
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
@ -335,7 +336,7 @@ termux-chroot ./picoclaw onboard # chroot provides a standard Linux filesystem
|
||||||
|
|
||||||
Sau đó làm theo phần Terminal Launcher bên dưới để hoàn tất cấu hình.
|
Sau đó làm theo phần Terminal Launcher bên dưới để hoàn tất cấu hình.
|
||||||
|
|
||||||
<img src="../../assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||||
|
|
||||||
Đối với các môi trường tối giản chỉ có binary lõi `picoclaw` (không có Launcher UI), bạn có thể cấu hình mọi thứ qua dòng lệnh và tệp cấu hình JSON.
|
Đối với các môi trường tối giản chỉ có binary lõi `picoclaw` (không có Launcher UI), bạn có thể cấu hình mọi thứ qua dòng lệnh và tệp cấu hình JSON.
|
||||||
|
|
||||||
|
|
@ -351,7 +352,6 @@ Lệnh này tạo `~/.picoclaw/config.json` và thư mục workspace.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 3,
|
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model_name": "gpt-5.4"
|
"model_name": "gpt-5.4"
|
||||||
|
|
@ -361,7 +361,7 @@ Lệnh này tạo `~/.picoclaw/config.json` và thư mục workspace.
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_keys": ["sk-your-api-key"]
|
"api_key": "sk-your-api-key"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -442,7 +442,7 @@ PicoClaw hỗ trợ 30+ Provider LLM thông qua cấu hình `model_list`. Sử d
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Để biết chi tiết cấu hình provider đầy đủ, xem [Providers & Models](../guides/providers.vi.md).
|
Để biết chi tiết cấu hình provider đầy đủ, xem [Providers & Models](docs/vi/providers.md).
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -452,28 +452,28 @@ Trò chuyện với PicoClaw của bạn qua 17+ nền tảng nhắn tin:
|
||||||
|
|
||||||
| Channel | Thiết lập | Protocol | Tài liệu |
|
| Channel | Thiết lập | Protocol | Tài liệu |
|
||||||
|---------|-----------|----------|----------|
|
|---------|-----------|----------|----------|
|
||||||
| **Telegram** | Dễ (bot token) | Long polling | [Hướng dẫn](../channels/telegram/README.vi.md) |
|
| **Telegram** | Dễ (bot token) | Long polling | [Hướng dẫn](docs/channels/telegram/README.vi.md) |
|
||||||
| **Discord** | Dễ (bot token + intents) | WebSocket | [Hướng dẫn](../channels/discord/README.vi.md) |
|
| **Discord** | Dễ (bot token + intents) | WebSocket | [Hướng dẫn](docs/channels/discord/README.vi.md) |
|
||||||
| **WhatsApp** | Dễ (quét QR hoặc bridge URL) | Native / Bridge | [Hướng dẫn](../guides/chat-apps.vi.md#whatsapp) |
|
| **WhatsApp** | Dễ (quét QR hoặc bridge URL) | Native / Bridge | [Hướng dẫn](docs/vi/chat-apps.md#whatsapp) |
|
||||||
| **Weixin** | Dễ (quét QR gốc) | iLink API | [Hướng dẫn](../guides/chat-apps.vi.md#weixin) |
|
| **Weixin** | Dễ (quét QR gốc) | iLink API | [Hướng dẫn](docs/vi/chat-apps.md#weixin) |
|
||||||
| **QQ** | Dễ (AppID + AppSecret) | WebSocket | [Hướng dẫn](../channels/qq/README.vi.md) |
|
| **QQ** | Dễ (AppID + AppSecret) | WebSocket | [Hướng dẫn](docs/channels/qq/README.vi.md) |
|
||||||
| **Slack** | Dễ (bot + app token) | Socket Mode | [Hướng dẫn](../channels/slack/README.vi.md) |
|
| **Slack** | Dễ (bot + app token) | Socket Mode | [Hướng dẫn](docs/channels/slack/README.vi.md) |
|
||||||
| **Matrix** | Trung bình (homeserver + token) | Sync API | [Hướng dẫn](../channels/matrix/README.vi.md) |
|
| **Matrix** | Trung bình (homeserver + token) | Sync API | [Hướng dẫn](docs/channels/matrix/README.vi.md) |
|
||||||
| **DingTalk** | Trung bình (client credentials) | Stream | [Hướng dẫn](../channels/dingtalk/README.vi.md) |
|
| **DingTalk** | Trung bình (client credentials) | Stream | [Hướng dẫn](docs/channels/dingtalk/README.vi.md) |
|
||||||
| **Feishu / Lark** | Trung bình (App ID + Secret) | WebSocket/SDK | [Hướng dẫn](../channels/feishu/README.vi.md) |
|
| **Feishu / Lark** | Trung bình (App ID + Secret) | WebSocket/SDK | [Hướng dẫn](docs/channels/feishu/README.vi.md) |
|
||||||
| **LINE** | Trung bình (credentials + webhook) | Webhook | [Hướng dẫn](../channels/line/README.vi.md) |
|
| **LINE** | Trung bình (credentials + webhook) | Webhook | [Hướng dẫn](docs/channels/line/README.vi.md) |
|
||||||
| **WeCom** | Dễ (đăng nhập QR hoặc thủ công) | WebSocket | [Hướng dẫn](../channels/wecom/README.vi.md) |
|
| **WeCom** | Dễ (đăng nhập QR hoặc thủ công) | WebSocket | [Hướng dẫn](docs/channels/wecom/README.md) |
|
||||||
| **IRC** | Trung bình (server + nick) | IRC protocol | [Hướng dẫn](../guides/chat-apps.vi.md#irc) |
|
| **IRC** | Trung bình (server + nick) | IRC protocol | [Hướng dẫn](docs/vi/chat-apps.md#irc) |
|
||||||
| **OneBot** | Trung bình (WebSocket URL) | OneBot v11 | [Hướng dẫn](../channels/onebot/README.vi.md) |
|
| **OneBot** | Trung bình (WebSocket URL) | OneBot v11 | [Hướng dẫn](docs/channels/onebot/README.vi.md) |
|
||||||
| **MaixCam** | Dễ (bật) | TCP socket | [Hướng dẫn](../channels/maixcam/README.vi.md) |
|
| **MaixCam** | Dễ (bật) | TCP socket | [Hướng dẫn](docs/channels/maixcam/README.vi.md) |
|
||||||
| **Pico** | Dễ (bật) | Native protocol | Tích hợp sẵn |
|
| **Pico** | Dễ (bật) | Native protocol | Tích hợp sẵn |
|
||||||
| **Pico Client** | Dễ (WebSocket URL) | WebSocket | Tích hợp sẵn |
|
| **Pico Client** | Dễ (WebSocket URL) | WebSocket | Tích hợp sẵn |
|
||||||
|
|
||||||
> Tất cả các Channel dựa trên webhook dùng chung một Gateway HTTP server (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Feishu sử dụng chế độ WebSocket/SDK và không dùng HTTP server chung.
|
> Tất cả các Channel dựa trên webhook dùng chung một Gateway HTTP server (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Feishu sử dụng chế độ WebSocket/SDK và không dùng HTTP server chung.
|
||||||
|
|
||||||
> Mức độ chi tiết log được kiểm soát bởi `gateway.log_level` (mặc định: `warn`). Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`. Cũng có thể đặt qua `PICOCLAW_LOG_LEVEL`. Xem [Cấu hình](../guides/configuration.vi.md#mức-log-của-gateway) để biết thêm chi tiết.
|
> Mức độ chi tiết log được kiểm soát bởi `gateway.log_level` (mặc định: `warn`). Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`. Cũng có thể đặt qua `PICOCLAW_LOG_LEVEL`. Xem [Cấu hình](docs/vi/configuration.md#mức-log-của-gateway) để biết thêm chi tiết.
|
||||||
|
|
||||||
Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](../guides/chat-apps.vi.md).
|
Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](docs/vi/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Tools
|
## 🔧 Tools
|
||||||
|
|
||||||
|
|
@ -484,7 +484,7 @@ PicoClaw có thể tìm kiếm web để cung cấp thông tin cập nhật. C
|
||||||
| Công cụ Tìm kiếm | API Key | Gói miễn phí | Liên kết |
|
| Công cụ Tìm kiếm | API Key | Gói miễn phí | Liên kết |
|
||||||
|------------------|---------|--------------|----------|
|
|------------------|---------|--------------|----------|
|
||||||
| DuckDuckGo | Không cần | Không giới hạn | Dự phòng tích hợp sẵn |
|
| DuckDuckGo | Không cần | Không giới hạn | Dự phòng tích hợp sẵn |
|
||||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Bắt buộc | 1500 truy vấn/tháng (phân bổ hàng ngày) | AI, tối ưu cho tiếng Trung |
|
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Bắt buộc | 1000 truy vấn/ngày | AI, tối ưu cho tiếng Trung |
|
||||||
| [Tavily](https://tavily.com) | Bắt buộc | 1000 truy vấn/tháng | Tối ưu cho AI Agent |
|
| [Tavily](https://tavily.com) | Bắt buộc | 1000 truy vấn/tháng | Tối ưu cho AI Agent |
|
||||||
| [Brave Search](https://brave.com/search/api) | Bắt buộc | 2000 truy vấn/tháng | Nhanh và riêng tư |
|
| [Brave Search](https://brave.com/search/api) | Bắt buộc | 2000 truy vấn/tháng | Nhanh và riêng tư |
|
||||||
| [Perplexity](https://www.perplexity.ai) | Bắt buộc | Trả phí | Tìm kiếm hỗ trợ AI |
|
| [Perplexity](https://www.perplexity.ai) | Bắt buộc | Trả phí | Tìm kiếm hỗ trợ AI |
|
||||||
|
|
@ -493,7 +493,7 @@ PicoClaw có thể tìm kiếm web để cung cấp thông tin cập nhật. C
|
||||||
|
|
||||||
### ⚙️ Các Tools Khác
|
### ⚙️ Các Tools Khác
|
||||||
|
|
||||||
PicoClaw bao gồm các tool tích hợp sẵn cho thao tác tệp, thực thi mã, lên lịch và nhiều hơn nữa. Xem [Cấu hình Tools](../reference/tools_configuration.vi.md) để biết chi tiết.
|
PicoClaw bao gồm các tool tích hợp sẵn cho thao tác tệp, thực thi mã, lên lịch và nhiều hơn nữa. Xem [Cấu hình Tools](docs/vi/tools_configuration.md) để biết chi tiết.
|
||||||
|
|
||||||
## 🎯 Skills
|
## 🎯 Skills
|
||||||
|
|
||||||
|
|
@ -523,7 +523,7 @@ Thêm vào `config.json` của bạn:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Để biết thêm chi tiết, xem [Cấu hình Tools - Skills](../reference/tools_configuration.vi.md#skills-tool).
|
Để biết thêm chi tiết, xem [Cấu hình Tools - Skills](docs/vi/tools_configuration.md#skills-tool).
|
||||||
|
|
||||||
## 🔗 MCP (Model Context Protocol)
|
## 🔗 MCP (Model Context Protocol)
|
||||||
|
|
||||||
|
|
@ -546,9 +546,9 @@ PicoClaw hỗ trợ [MCP](https://modelcontextprotocol.io/) gốc — kết nố
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Để biết cấu hình MCP đầy đủ (stdio, SSE, HTTP transports, Tool Discovery), xem [Cấu hình Tools - MCP](../reference/tools_configuration.vi.md#mcp-tool).
|
Để biết cấu hình MCP đầy đủ (stdio, SSE, HTTP transports, Tool Discovery), xem [Cấu hình Tools - MCP](docs/vi/tools_configuration.md#mcp-tool).
|
||||||
|
|
||||||
## <img src="../../assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Tham gia Mạng xã hội Agent
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Tham gia Mạng xã hội Agent
|
||||||
|
|
||||||
Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn duy nhất qua CLI hoặc bất kỳ Ứng dụng Chat nào đã tích hợp.
|
Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn duy nhất qua CLI hoặc bất kỳ Ứng dụng Chat nào đã tích hợp.
|
||||||
|
|
||||||
|
|
@ -589,23 +589,23 @@ PicoClaw hỗ trợ nhắc nhở đã lên lịch và tác vụ định kỳ th
|
||||||
|
|
||||||
| Chủ đề | Mô tả |
|
| Chủ đề | Mô tả |
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| [Docker & Khởi động Nhanh](../guides/docker.vi.md) | Thiết lập Docker Compose, chế độ Launcher/Agent |
|
| [Docker & Khởi động Nhanh](docs/vi/docker.md) | Thiết lập Docker Compose, chế độ Launcher/Agent |
|
||||||
| [Ứng dụng Chat](../guides/chat-apps.vi.md) | Hướng dẫn thiết lập 17+ Channel |
|
| [Ứng dụng Chat](docs/vi/chat-apps.md) | Hướng dẫn thiết lập 17+ Channel |
|
||||||
| [Cấu hình](../guides/configuration.vi.md) | Biến môi trường, bố cục workspace, sandbox bảo mật |
|
| [Cấu hình](docs/vi/configuration.md) | Biến môi trường, bố cục workspace, sandbox bảo mật |
|
||||||
| [Providers & Models](../guides/providers.vi.md) | 30+ Provider LLM, định tuyến mô hình, cấu hình model_list |
|
| [Providers & Models](docs/vi/providers.md) | 30+ Provider LLM, định tuyến mô hình, cấu hình model_list |
|
||||||
| [Spawn & Tác vụ Bất đồng bộ](../guides/spawn-tasks.vi.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ |
|
| [Spawn & Tác vụ Bất đồng bộ](docs/vi/spawn-tasks.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ |
|
||||||
| [Hooks](../architecture/hooks/README.md) | Hệ thống hook hướng sự kiện: observer, interceptor, approval hook |
|
| [Hooks](docs/hooks/README.md) | Hệ thống hook hướng sự kiện: observer, interceptor, approval hook |
|
||||||
| [Steering](../architecture/steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy |
|
| [Steering](docs/steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy |
|
||||||
| [SubTurn](../architecture/subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời |
|
| [SubTurn](docs/subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời |
|
||||||
| [Khắc phục sự cố](../operations/troubleshooting.vi.md) | Các vấn đề thường gặp và giải pháp |
|
| [Khắc phục sự cố](docs/vi/troubleshooting.md) | Các vấn đề thường gặp và giải pháp |
|
||||||
| [Cấu hình Tools](../reference/tools_configuration.vi.md) | Bật/tắt từng tool, chính sách exec, MCP, Skills |
|
| [Cấu hình Tools](docs/vi/tools_configuration.md) | Bật/tắt từng tool, chính sách exec, MCP, Skills |
|
||||||
| [Tương thích Phần cứng](../guides/hardware-compatibility.vi.md) | Các board đã kiểm tra, yêu cầu tối thiểu |
|
| [Tương thích Phần cứng](docs/vi/hardware-compatibility.md) | Các board đã kiểm tra, yêu cầu tối thiểu |
|
||||||
|
|
||||||
## 🤝 Đóng góp & Lộ trình
|
## 🤝 Đóng góp & Lộ trình
|
||||||
|
|
||||||
PR luôn được chào đón! Codebase được thiết kế nhỏ gọn và dễ đọc.
|
PR luôn được chào đón! Codebase được thiết kế nhỏ gọn và dễ đọc.
|
||||||
|
|
||||||
Xem [Lộ trình Cộng đồng](https://github.com/sipeed/picoclaw/issues/988) và [CONTRIBUTING.md](../../CONTRIBUTING.md) để biết hướng dẫn.
|
Xem [Lộ trình Cộng đồng](https://github.com/sipeed/picoclaw/issues/988) và [CONTRIBUTING.md](CONTRIBUTING.md) để biết hướng dẫn.
|
||||||
|
|
||||||
Nhóm nhà phát triển đang được xây dựng, tham gia sau khi PR đầu tiên của bạn được merge!
|
Nhóm nhà phát triển đang được xây dựng, tham gia sau khi PR đầu tiên của bạn được merge!
|
||||||
|
|
||||||
|
|
@ -614,4 +614,4 @@ Nhóm Người dùng:
|
||||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
WeChat:
|
WeChat:
|
||||||
<img src="../../assets/wechat.png" alt="WeChat group QR code" width="512">
|
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="../../assets/logo.webp" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
||||||
|
|
||||||
|
|
@ -14,11 +14,11 @@
|
||||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||||
<br>
|
<br>
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
<a href="../../assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -34,12 +34,12 @@
|
||||||
<tr align="center">
|
<tr align="center">
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/picoclaw_mem.gif" width="360" height="240">
|
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/licheervnano.png" width="400" height="240">
|
<img src="assets/licheervnano.png" width="400" height="240">
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -56,14 +56,6 @@
|
||||||
|
|
||||||
## 📢 新闻
|
## 📢 新闻
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw 已上架淘宝!** 现在可以在 [淘宝](https://item.taobao.com/item.htm?abbucket=20&id=764939520376) 购买 LicheeRV-Claw,更方便地在小型 RISC-V 硬件上体验 PicoClaw。
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://item.taobao.com/item.htm?abbucket=20&id=764939520376">
|
|
||||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on Taobao" width="520">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
2026-03-31 📱 **Android 支持!** PicoClaw 现可在 Android 上运行!APK 下载地址:[picoclaw.io](https://picoclaw.io/download)
|
2026-03-31 📱 **Android 支持!** PicoClaw 现可在 Android 上运行!APK 下载地址:[picoclaw.io](https://picoclaw.io/download)
|
||||||
|
|
||||||
2026-03-25 🚀 **v0.2.4 发布!** Agent 架构全面重构(SubTurn、Hook、Steering、EventBus)、微信/企业微信深度集成、安全体系升级(.security.yml、敏感数据过滤)、新增 Provider(AWS Bedrock、Azure、小米 MiMo),以及 35 项 Bug 修复。PicoClaw 已达 **26K ⭐**!
|
2026-03-25 🚀 **v0.2.4 发布!** Agent 架构全面重构(SubTurn、Hook、Steering、EventBus)、微信/企业微信深度集成、安全体系升级(.security.yml、敏感数据过滤)、新增 Provider(AWS Bedrock、Azure、小米 MiMo),以及 35 项 Bug 修复。PicoClaw 已达 **26K ⭐**!
|
||||||
|
|
@ -79,7 +71,7 @@
|
||||||
|
|
||||||
2026-02-26 🎉 PicoClaw 仅 17 天突破 **20K Stars**!频道自动编排和能力接口上线。
|
2026-02-26 🎉 PicoClaw 仅 17 天突破 **20K Stars**!频道自动编排和能力接口上线。
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw 一周内突破 12K Stars!社区维护者角色和 [路线图](../../ROADMAP.md) 正式发布。
|
2026-02-16 🎉 PicoClaw 一周内突破 12K Stars!社区维护者角色和 [路线图](ROADMAP.md) 正式发布。
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw 4 天内突破 5000 Stars!项目路线图和开发者群组筹建中。
|
2026-02-13 🎉 PicoClaw 4 天内突破 5000 Stars!项目路线图和开发者群组筹建中。
|
||||||
|
|
||||||
|
|
@ -116,14 +108,14 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入
|
||||||
| **启动时间**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
| **启动时间**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
||||||
| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**</br>**低至 $10** |
|
| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**</br>**低至 $10** |
|
||||||
|
|
||||||
<img src="../../assets/compare.jpg" alt="PicoClaw" width="512">
|
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> 📋 **[硬件兼容列表](../guides/hardware-compatibility.zh.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR!
|
> 📋 **[硬件兼容列表](docs/zh/hardware-compatibility.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR!
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 🦾 演示
|
## 🦾 演示
|
||||||
|
|
@ -137,9 +129,9 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入
|
||||||
<th><p align="center">🔎 网络搜索与学习</p></th>
|
<th><p align="center">🔎 网络搜索与学习</p></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||||
<td align="center"><p align="center"><img src="../../assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">开发 • 部署 • 扩展</td>
|
<td align="center">开发 • 部署 • 扩展</td>
|
||||||
|
|
@ -152,9 +144,9 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入
|
||||||
|
|
||||||
PicoClaw 几乎可以部署在任何 Linux 设备上!
|
PicoClaw 几乎可以部署在任何 Linux 设备上!
|
||||||
|
|
||||||
- $9.9 [LicheeRV-Nano](https://item.taobao.com/item.htm?id=764939520376) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手
|
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手
|
||||||
- $30~50 [NanoKVM](https://item.taobao.com/item.htm?id=811206560480),或 $100 [NanoKVM-Pro](https://item.taobao.com/item.htm?id=994419942411),用于自动化服务器运维
|
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维
|
||||||
- $50 [MaixCAM](https://item.taobao.com/item.htm?id=784724795837) 或 $100 [MaixCAM2](https://item.taobao.com/item.htm?id=1050380368975),用于智能监控
|
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控
|
||||||
|
|
||||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||||
|
|
||||||
|
|
@ -172,27 +164,19 @@ PicoClaw 几乎可以部署在任何 Linux 设备上!
|
||||||
|
|
||||||
### 从源码构建(开发用)
|
### 从源码构建(开发用)
|
||||||
|
|
||||||
前置要求:
|
|
||||||
|
|
||||||
- Go 1.25+
|
|
||||||
- Node.js 22+ 和 pnpm 10.33.0+(用于 Web UI / launcher 构建)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# 安装前端依赖
|
|
||||||
(cd web/frontend && pnpm install --frozen-lockfile)
|
|
||||||
|
|
||||||
# 构建核心二进制文件
|
# 构建核心二进制文件
|
||||||
make build
|
make build
|
||||||
|
|
||||||
# 构建 Web UI Launcher(WebUI 模式必需)
|
# 构建 Web UI Launcher(WebUI 模式必需)
|
||||||
make build-launcher
|
make build-launcher
|
||||||
|
|
||||||
# 为 Makefile 管理的所有平台构建核心二进制文件
|
# 为多平台构建
|
||||||
make build-all
|
make build-all
|
||||||
|
|
||||||
# 为 Raspberry Pi Zero 2 W 构建(32位: make build-linux-arm; 64位: make build-linux-arm64)
|
# 为 Raspberry Pi Zero 2 W 构建(32位: make build-linux-arm; 64位: make build-linux-arm64)
|
||||||
|
|
@ -228,7 +212,7 @@ picoclaw-launcher
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**开始使用:**
|
**开始使用:**
|
||||||
|
|
@ -282,7 +266,7 @@ macOS 可能会在首次启动时拦截 `picoclaw-launcher`,因为它从互联
|
||||||
**第一步:** 双击 `picoclaw-launcher`,会出现安全警告:
|
**第一步:** 双击 `picoclaw-launcher`,会出现安全警告:
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/macos-gatekeeper-warning.jpg" alt="macOS Gatekeeper 警告" width="400">
|
<img src="assets/macos-gatekeeper-warning.jpg" alt="macOS Gatekeeper 警告" width="400">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> *"picoclaw-launcher" 无法打开 — Apple 无法验证 "picoclaw-launcher" 不含可能损害 Mac 或危及隐私的恶意软件。*
|
> *"picoclaw-launcher" 无法打开 — Apple 无法验证 "picoclaw-launcher" 不含可能损害 Mac 或危及隐私的恶意软件。*
|
||||||
|
|
@ -290,14 +274,31 @@ macOS 可能会在首次启动时拦截 `picoclaw-launcher`,因为它从互联
|
||||||
**第二步:** 打开**系统设置** → **隐私与安全性** → 向下滚动找到**安全性**部分 → 点击**仍要打开** → 在弹窗中再次点击**打开**。
|
**第二步:** 打开**系统设置** → **隐私与安全性** → 向下滚动找到**安全性**部分 → 点击**仍要打开** → 在弹窗中再次点击**打开**。
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../../assets/macos-gatekeeper-allow.jpg" alt="macOS 隐私与安全性 — 仍要打开" width="600">
|
<img src="assets/macos-gatekeeper-allow.jpg" alt="macOS 隐私与安全性 — 仍要打开" width="600">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
完成这一次操作后,后续启动 `picoclaw-launcher` 将不再弹出警告。
|
完成这一次操作后,后续启动 `picoclaw-launcher` 将不再弹出警告。
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<a id="-run-on-old-android-phones"></a>
|
### 💻 TUI Launcher(推荐无头环境 / SSH)
|
||||||
|
|
||||||
|
TUI(终端 UI)Launcher 提供功能完整的终端配置与管理界面,适合服务器、树莓派等无显示器环境。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw-launcher-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**开始使用:**
|
||||||
|
|
||||||
|
通过 TUI 菜单:**1)** 配置 Provider -> **2)** 配置 Channel -> **3)** 启动 Gateway -> **4)** 开始聊天!
|
||||||
|
|
||||||
|
详细 TUI 文档请参阅 [docs.picoclaw.io](https://docs.picoclaw.io)。
|
||||||
|
|
||||||
### 📱 Android
|
### 📱 Android
|
||||||
|
|
||||||
让你十年前的旧手机焕发新生!将它变成你的 AI 助手。
|
让你十年前的旧手机焕发新生!将它变成你的 AI 助手。
|
||||||
|
|
@ -308,10 +309,10 @@ macOS 可能会在首次启动时拦截 `picoclaw-launcher`,因为它从互联
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td><img src="../../assets/fui_main_page.jpg" width="200"></td>
|
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_web_page.jpg" width="200"></td>
|
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_log_page.jpg" width="200"></td>
|
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||||
<td><img src="../../assets/fui_setting_page.jpg" width="200"></td>
|
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
@ -335,7 +336,7 @@ termux-chroot ./picoclaw onboard # chroot 提供标准 Linux 文件系统布
|
||||||
|
|
||||||
然后跟随下面的"Terminal Launcher"章节继续配置。
|
然后跟随下面的"Terminal Launcher"章节继续配置。
|
||||||
|
|
||||||
<img src="../../assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||||
|
|
||||||
对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。
|
对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。
|
||||||
|
|
||||||
|
|
@ -351,7 +352,6 @@ picoclaw onboard
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 3,
|
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model_name": "gpt-5.4"
|
"model_name": "gpt-5.4"
|
||||||
|
|
@ -361,7 +361,7 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_keys": ["sk-your-api-key"]
|
"api_key": "sk-your-api-key"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -442,7 +442,7 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
完整 Provider 配置详情请参阅 [Providers & Models](../guides/providers.zh.md)。
|
完整 Provider 配置详情请参阅 [Providers & Models](docs/zh/providers.md)。
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -452,29 +452,29 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模
|
||||||
|
|
||||||
| Channel | 配置难度 | 协议 | 文档 |
|
| Channel | 配置难度 | 协议 | 文档 |
|
||||||
|---------|----------|------|------|
|
|---------|----------|------|------|
|
||||||
| **Telegram** | 简单(bot token) | 长轮询 | [指南](../channels/telegram/README.zh.md) |
|
| **Telegram** | 简单(bot token) | 长轮询 | [指南](docs/channels/telegram/README.zh.md) |
|
||||||
| **Discord** | 简单(bot token + intents) | WebSocket | [指南](../channels/discord/README.zh.md) |
|
| **Discord** | 简单(bot token + intents) | WebSocket | [指南](docs/channels/discord/README.zh.md) |
|
||||||
| **WhatsApp** | 简单(扫码或 bridge URL) | 原生 / Bridge | [指南](../guides/chat-apps.zh.md#whatsapp) |
|
| **WhatsApp** | 简单(扫码或 bridge URL) | 原生 / Bridge | [指南](docs/zh/chat-apps.md#whatsapp) |
|
||||||
| **微信 (Weixin)** | 简单(扫码登录) | iLink API | [指南](../guides/chat-apps.zh.md#weixin) |
|
| **微信 (Weixin)** | 简单(扫码登录) | iLink API | [指南](docs/zh/chat-apps.md#weixin) |
|
||||||
| **QQ** | 简单(AppID + AppSecret) | WebSocket | [指南](../channels/qq/README.zh.md) |
|
| **QQ** | 简单(AppID + AppSecret) | WebSocket | [指南](docs/channels/qq/README.zh.md) |
|
||||||
| **Slack** | 简单(bot + app token) | Socket Mode | [指南](../channels/slack/README.zh.md) |
|
| **Slack** | 简单(bot + app token) | Socket Mode | [指南](docs/channels/slack/README.zh.md) |
|
||||||
| **Matrix** | 中等(homeserver + token) | Sync API | [指南](../channels/matrix/README.zh.md) |
|
| **Matrix** | 中等(homeserver + token) | Sync API | [指南](docs/channels/matrix/README.zh.md) |
|
||||||
| **钉钉** | 中等(client credentials) | Stream | [指南](../channels/dingtalk/README.zh.md) |
|
| **钉钉** | 中等(client credentials) | Stream | [指南](docs/channels/dingtalk/README.zh.md) |
|
||||||
| **飞书 / Lark** | 中等(App ID + Secret) | WebSocket/SDK | [指南](../channels/feishu/README.zh.md) |
|
| **飞书 / Lark** | 中等(App ID + Secret) | WebSocket/SDK | [指南](docs/channels/feishu/README.zh.md) |
|
||||||
| **LINE** | 中等(credentials + webhook) | Webhook | [指南](../channels/line/README.zh.md) |
|
| **LINE** | 中等(credentials + webhook) | Webhook | [指南](docs/channels/line/README.zh.md) |
|
||||||
| **企业微信** | 简单(扫码登录或手动配置) | WebSocket | [指南](../channels/wecom/README.zh.md) |
|
| **企业微信** | 简单(扫码登录或手动配置) | WebSocket | [指南](docs/channels/wecom/README.zh.md) |
|
||||||
| **VK** | 简单(群组 token) | Long Poll | [指南](../channels/vk/README.md) |
|
| **VK** | 简单(群组 token) | Long Poll | [指南](docs/channels/vk/README.md) |
|
||||||
| **IRC** | 中等(server + nick) | IRC 协议 | [指南](../guides/chat-apps.zh.md#irc) |
|
| **IRC** | 中等(server + nick) | IRC 协议 | [指南](docs/zh/chat-apps.md#irc) |
|
||||||
| **OneBot** | 中等(WebSocket URL) | OneBot v11 | [指南](../channels/onebot/README.zh.md) |
|
| **OneBot** | 中等(WebSocket URL) | OneBot v11 | [指南](docs/channels/onebot/README.zh.md) |
|
||||||
| **MaixCam** | 简单(启用即可) | TCP socket | [指南](../channels/maixcam/README.zh.md) |
|
| **MaixCam** | 简单(启用即可) | TCP socket | [指南](docs/channels/maixcam/README.zh.md) |
|
||||||
| **Pico** | 简单(启用即可) | 原生协议 | 内置 |
|
| **Pico** | 简单(启用即可) | 原生协议 | 内置 |
|
||||||
| **Pico Client** | 简单(WebSocket URL) | WebSocket | 内置 |
|
| **Pico Client** | 简单(WebSocket URL) | WebSocket | 内置 |
|
||||||
|
|
||||||
> 所有基于 Webhook 的 Channel 共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。飞书使用 WebSocket/SDK 模式,不使用共享 HTTP 服务器。
|
> 所有基于 Webhook 的 Channel 共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。飞书使用 WebSocket/SDK 模式,不使用共享 HTTP 服务器。
|
||||||
|
|
||||||
> 日志详细程度通过 `gateway.log_level` 控制(默认:`warn`)。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。也可通过 `PICOCLAW_LOG_LEVEL` 环境变量设置。详见[配置指南](../guides/configuration.zh.md#gateway-日志等级)。
|
> 日志详细程度通过 `gateway.log_level` 控制(默认:`warn`)。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。也可通过 `PICOCLAW_LOG_LEVEL` 环境变量设置。详见[配置指南](docs/zh/configuration.md#gateway-日志等级)。
|
||||||
|
|
||||||
详细 Channel 配置说明请参阅 [聊天应用配置](../guides/chat-apps.zh.md)。
|
详细 Channel 配置说明请参阅 [聊天应用配置](docs/zh/chat-apps.md)。
|
||||||
|
|
||||||
## 🔧 Tools
|
## 🔧 Tools
|
||||||
|
|
||||||
|
|
@ -484,7 +484,7 @@ PicoClaw 可以搜索网络以提供最新信息。在 `tools.web` 中配置:
|
||||||
|
|
||||||
| 搜索引擎 | API Key | 免费额度 | 链接 |
|
| 搜索引擎 | API Key | 免费额度 | 链接 |
|
||||||
|---------|---------|---------|------|
|
|---------|---------|---------|------|
|
||||||
| [百度搜索](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必填 | 1500 次/月(按天发放) | AI 搜索,国内首选 |
|
| [百度搜索](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必填 | 1000 次/天 | AI 搜索,国内首选 |
|
||||||
| [Tavily](https://tavily.com) | 必填 | 1000 次/月 | 专为 AI Agent 优化 |
|
| [Tavily](https://tavily.com) | 必填 | 1000 次/月 | 专为 AI Agent 优化 |
|
||||||
| [GLM Search](https://open.bigmodel.cn/) | 必填 | 视情况 | 智谱网络搜索 |
|
| [GLM Search](https://open.bigmodel.cn/) | 必填 | 视情况 | 智谱网络搜索 |
|
||||||
| DuckDuckGo | 无需 | 无限制 | 内置备用(国内访问困难) |
|
| DuckDuckGo | 无需 | 无限制 | 内置备用(国内访问困难) |
|
||||||
|
|
@ -494,7 +494,7 @@ PicoClaw 可以搜索网络以提供最新信息。在 `tools.web` 中配置:
|
||||||
|
|
||||||
### ⚙️ 其他工具
|
### ⚙️ 其他工具
|
||||||
|
|
||||||
PicoClaw 内置文件操作、代码执行、定时任务等工具。详情请参阅 [工具配置](../reference/tools_configuration.zh.md)。
|
PicoClaw 内置文件操作、代码执行、定时任务等工具。详情请参阅 [工具配置](docs/zh/tools_configuration.md)。
|
||||||
|
|
||||||
## 🎯 Skills
|
## 🎯 Skills
|
||||||
|
|
||||||
|
|
@ -507,7 +507,7 @@ picoclaw skills search "web scraping"
|
||||||
picoclaw skills install <skill-name>
|
picoclaw skills install <skill-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
**配置 Skills 仓库源**:
|
**配置 ClawHub token**(可选,用于提高速率限制):
|
||||||
|
|
||||||
在 `config.json` 中添加:
|
在 `config.json` 中添加:
|
||||||
```json
|
```json
|
||||||
|
|
@ -517,11 +517,6 @@ picoclaw skills install <skill-name>
|
||||||
"registries": {
|
"registries": {
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"auth_token": "your-clawhub-token"
|
"auth_token": "your-clawhub-token"
|
||||||
},
|
|
||||||
"github": {
|
|
||||||
"base_url": "https://github.com",
|
|
||||||
"auth_token": "your-github-token",
|
|
||||||
"proxy": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -529,9 +524,7 @@ picoclaw skills install <skill-name>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`tools.skills.github.*` 已废弃,请改用 `tools.skills.registries.github.*`。
|
更多详情请参阅 [工具配置 - Skills](docs/zh/tools_configuration.md#skills-tool)。
|
||||||
|
|
||||||
更多详情请参阅 [工具配置 - Skills](../reference/tools_configuration.zh.md#skills-tool)。
|
|
||||||
|
|
||||||
## 🔗 MCP (Model Context Protocol)
|
## 🔗 MCP (Model Context Protocol)
|
||||||
|
|
||||||
|
|
@ -554,9 +547,9 @@ PicoClaw 原生支持 [MCP](https://modelcontextprotocol.io/) — 连接任意 M
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
完整 MCP 配置(stdio、SSE、HTTP 传输、Tool Discovery)请参阅 [工具配置 - MCP](../reference/tools_configuration.zh.md#mcp-tool)。
|
完整 MCP 配置(stdio、SSE、HTTP 传输、Tool Discovery)请参阅 [工具配置 - MCP](docs/zh/tools_configuration.md#mcp-tool)。
|
||||||
|
|
||||||
## <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 社交网络。
|
||||||
|
|
||||||
|
|
@ -597,23 +590,23 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
||||||
|
|
||||||
| 主题 | 说明 |
|
| 主题 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 🐳 [Docker 与快速开始](../guides/docker.zh.md) | Docker Compose 配置、Launcher/Agent 模式、快速开始 |
|
| 🐳 [Docker 与快速开始](docs/zh/docker.md) | Docker Compose 配置、Launcher/Agent 模式、快速开始 |
|
||||||
| 💬 [聊天应用配置](../guides/chat-apps.zh.md) | 全部 17+ Channel 配置指南 |
|
| 💬 [聊天应用配置](docs/zh/chat-apps.md) | 全部 17+ Channel 配置指南 |
|
||||||
| ⚙️ [配置指南](../guides/configuration.zh.md) | 环境变量、工作区布局、安全沙箱 |
|
| ⚙️ [配置指南](docs/zh/configuration.md) | 环境变量、工作区布局、安全沙箱 |
|
||||||
| 🔌 [提供商与模型配置](../guides/providers.zh.md) | 30+ LLM Provider、模型路由、model_list 配置 |
|
| 🔌 [提供商与模型配置](docs/zh/providers.md) | 30+ LLM Provider、模型路由、model_list 配置 |
|
||||||
| 🔄 [异步任务与 Spawn](../guides/spawn-tasks.zh.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 |
|
| 🔄 [异步任务与 Spawn](docs/zh/spawn-tasks.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 |
|
||||||
| 🪝 [Hook 系统](../architecture/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook |
|
| 🪝 [Hook 系统](docs/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook |
|
||||||
| 🎯 [Steering](../architecture/steering.md) | 在工具调用间向运行中的 Agent 注入消息 |
|
| 🎯 [Steering](docs/steering.md) | 在工具调用间向运行中的 Agent 注入消息 |
|
||||||
| 🔀 [SubTurn](../architecture/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 |
|
| 🔀 [SubTurn](docs/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 |
|
||||||
| 🐛 [疑难解答](../operations/troubleshooting.zh.md) | 常见问题与解决方案 |
|
| 🐛 [疑难解答](docs/zh/troubleshooting.md) | 常见问题与解决方案 |
|
||||||
| 🔧 [工具配置](../reference/tools_configuration.zh.md) | 工具启用/禁用、执行策略、MCP、Skills |
|
| 🔧 [工具配置](docs/zh/tools_configuration.md) | 工具启用/禁用、执行策略、MCP、Skills |
|
||||||
| 📋 [硬件兼容列表](../guides/hardware-compatibility.zh.md) | 已测试板卡、最低要求 |
|
| 📋 [硬件兼容列表](docs/zh/hardware-compatibility.md) | 已测试板卡、最低要求 |
|
||||||
|
|
||||||
## 🤝 贡献与路线图
|
## 🤝 贡献与路线图
|
||||||
|
|
||||||
欢迎提交 PR!代码库刻意保持小巧和可读。🤗
|
欢迎提交 PR!代码库刻意保持小巧和可读。🤗
|
||||||
|
|
||||||
查看完整的 [社区路线图](https://github.com/sipeed/picoclaw/issues/988) 和 [CONTRIBUTING.md](../../CONTRIBUTING.md)。
|
查看完整的 [社区路线图](https://github.com/sipeed/picoclaw/issues/988) 和 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||||
|
|
||||||
开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。
|
开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。
|
||||||
|
|
||||||
|
|
@ -622,4 +615,9 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
||||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
WeChat:
|
WeChat:
|
||||||
<img src="../../assets/wechat.png" alt="WeChat group QR code" width="512">
|
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
BIN
assets/launcher-tui.jpg
Normal file
BIN
assets/launcher-tui.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 271 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 215 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 432 KiB After Width: | Height: | Size: 362 KiB |
|
|
@ -36,7 +36,6 @@ type AggMetrics struct {
|
||||||
OverallHitRate float64 `json:"overallHitRate"`
|
OverallHitRate float64 `json:"overallHitRate"`
|
||||||
ByCategory map[int]*CatMetrics `json:"byCategory"`
|
ByCategory map[int]*CatMetrics `json:"byCategory"`
|
||||||
TotalQuestions int `json:"totalQuestions"`
|
TotalQuestions int `json:"totalQuestions"`
|
||||||
ValidF1Count int `json:"validF1Count"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CatMetrics holds metrics for a single category.
|
// CatMetrics holds metrics for a single category.
|
||||||
|
|
@ -44,7 +43,6 @@ type CatMetrics struct {
|
||||||
F1 float64 `json:"f1"`
|
F1 float64 `json:"f1"`
|
||||||
HitRate float64 `json:"hitRate"`
|
HitRate float64 `json:"hitRate"`
|
||||||
QuestionCount int `json:"questionCount"`
|
QuestionCount int `json:"questionCount"`
|
||||||
ValidF1Count int `json:"validF1Count"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvalLegacy evaluates using legacy session store (raw history + budget truncation).
|
// EvalLegacy evaluates using legacy session store (raw history + budget truncation).
|
||||||
|
|
@ -203,64 +201,38 @@ func EvalSeahorse(
|
||||||
|
|
||||||
// aggregateMetrics computes overall and per-category metrics.
|
// aggregateMetrics computes overall and per-category metrics.
|
||||||
func aggregateMetrics(qaResults []QAResult) AggMetrics {
|
func aggregateMetrics(qaResults []QAResult) AggMetrics {
|
||||||
type catAccum struct {
|
byCat := map[int]*CatMetrics{}
|
||||||
f1Sum float64
|
|
||||||
f1Count int
|
|
||||||
hitRateSum float64
|
|
||||||
hitRateCount int
|
|
||||||
}
|
|
||||||
byCatAcc := map[int]*catAccum{}
|
|
||||||
totalF1 := 0.0
|
totalF1 := 0.0
|
||||||
totalHitRate := 0.0
|
totalHitRate := 0.0
|
||||||
validF1Count := 0
|
|
||||||
for _, qr := range qaResults {
|
for _, qr := range qaResults {
|
||||||
// Skip sentinel -1.0 scores (LLM API/parse failures) from F1 averaging.
|
totalF1 += qr.TokenF1
|
||||||
if qr.TokenF1 >= 0 {
|
|
||||||
totalF1 += qr.TokenF1
|
|
||||||
validF1Count++
|
|
||||||
}
|
|
||||||
totalHitRate += qr.HitRate
|
totalHitRate += qr.HitRate
|
||||||
acc, ok := byCatAcc[qr.Category]
|
cat, ok := byCat[qr.Category]
|
||||||
if !ok {
|
if !ok {
|
||||||
acc = &catAccum{}
|
cat = &CatMetrics{}
|
||||||
byCatAcc[qr.Category] = acc
|
byCat[qr.Category] = cat
|
||||||
}
|
}
|
||||||
if qr.TokenF1 >= 0 {
|
cat.F1 += qr.TokenF1
|
||||||
acc.f1Sum += qr.TokenF1
|
cat.HitRate += qr.HitRate
|
||||||
acc.f1Count++
|
cat.QuestionCount++
|
||||||
}
|
|
||||||
acc.hitRateSum += qr.HitRate
|
|
||||||
acc.hitRateCount++
|
|
||||||
}
|
}
|
||||||
nHit := len(qaResults)
|
n := len(qaResults)
|
||||||
if nHit == 0 {
|
if n == 0 {
|
||||||
nHit = 1
|
n = 1
|
||||||
}
|
}
|
||||||
byCat := map[int]*CatMetrics{}
|
agg := AggMetrics{
|
||||||
for cat, acc := range byCatAcc {
|
OverallF1: totalF1 / float64(n),
|
||||||
cm := &CatMetrics{
|
OverallHitRate: totalHitRate / float64(n),
|
||||||
QuestionCount: acc.hitRateCount,
|
|
||||||
ValidF1Count: acc.f1Count,
|
|
||||||
}
|
|
||||||
if acc.f1Count > 0 {
|
|
||||||
cm.F1 = acc.f1Sum / float64(acc.f1Count)
|
|
||||||
}
|
|
||||||
if acc.hitRateCount > 0 {
|
|
||||||
cm.HitRate = acc.hitRateSum / float64(acc.hitRateCount)
|
|
||||||
}
|
|
||||||
byCat[cat] = cm
|
|
||||||
}
|
|
||||||
var overallF1 float64
|
|
||||||
if validF1Count > 0 {
|
|
||||||
overallF1 = totalF1 / float64(validF1Count)
|
|
||||||
}
|
|
||||||
return AggMetrics{
|
|
||||||
OverallF1: overallF1,
|
|
||||||
OverallHitRate: totalHitRate / float64(nHit),
|
|
||||||
ByCategory: byCat,
|
ByCategory: byCat,
|
||||||
TotalQuestions: len(qaResults),
|
TotalQuestions: len(qaResults),
|
||||||
ValidF1Count: validF1Count,
|
|
||||||
}
|
}
|
||||||
|
for _, cat := range agg.ByCategory {
|
||||||
|
if cat.QuestionCount > 0 {
|
||||||
|
cat.F1 /= float64(cat.QuestionCount)
|
||||||
|
cat.HitRate /= float64(cat.QuestionCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return agg
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveResults writes per-sample eval results to JSON files.
|
// SaveResults writes per-sample eval results to JSON files.
|
||||||
|
|
@ -305,43 +277,27 @@ func SaveAggregated(results []EvalResult, outDir string) error {
|
||||||
func computeModeAgg(results []EvalResult) AggMetrics {
|
func computeModeAgg(results []EvalResult) AggMetrics {
|
||||||
agg := AggMetrics{ByCategory: map[int]*CatMetrics{}}
|
agg := AggMetrics{ByCategory: map[int]*CatMetrics{}}
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
// Backward compat: old eval JSON (token mode) without ValidF1Count → use TotalQuestions.
|
agg.OverallF1 += r.Agg.OverallF1 * float64(r.Agg.TotalQuestions)
|
||||||
// LLM modes may legitimately have ValidF1Count==0 (all failures).
|
|
||||||
vf1 := r.Agg.ValidF1Count
|
|
||||||
if vf1 == 0 && r.Agg.TotalQuestions > 0 && !strings.HasSuffix(r.Mode, "-llm") {
|
|
||||||
vf1 = r.Agg.TotalQuestions
|
|
||||||
}
|
|
||||||
agg.OverallF1 += r.Agg.OverallF1 * float64(vf1)
|
|
||||||
agg.OverallHitRate += r.Agg.OverallHitRate * float64(r.Agg.TotalQuestions)
|
agg.OverallHitRate += r.Agg.OverallHitRate * float64(r.Agg.TotalQuestions)
|
||||||
agg.TotalQuestions += r.Agg.TotalQuestions
|
agg.TotalQuestions += r.Agg.TotalQuestions
|
||||||
agg.ValidF1Count += vf1
|
|
||||||
for cat, cm := range r.Agg.ByCategory {
|
for cat, cm := range r.Agg.ByCategory {
|
||||||
existing, ok := agg.ByCategory[cat]
|
existing, ok := agg.ByCategory[cat]
|
||||||
if !ok {
|
if !ok {
|
||||||
existing = &CatMetrics{}
|
existing = &CatMetrics{}
|
||||||
agg.ByCategory[cat] = existing
|
agg.ByCategory[cat] = existing
|
||||||
}
|
}
|
||||||
cvf1 := cm.ValidF1Count
|
existing.F1 += cm.F1 * float64(cm.QuestionCount)
|
||||||
if cvf1 == 0 && cm.QuestionCount > 0 && !strings.HasSuffix(r.Mode, "-llm") {
|
|
||||||
cvf1 = cm.QuestionCount
|
|
||||||
}
|
|
||||||
existing.F1 += cm.F1 * float64(cvf1)
|
|
||||||
existing.HitRate += cm.HitRate * float64(cm.QuestionCount)
|
existing.HitRate += cm.HitRate * float64(cm.QuestionCount)
|
||||||
existing.QuestionCount += cm.QuestionCount
|
existing.QuestionCount += cm.QuestionCount
|
||||||
existing.ValidF1Count += cvf1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if agg.ValidF1Count > 0 {
|
|
||||||
agg.OverallF1 /= float64(agg.ValidF1Count)
|
|
||||||
}
|
|
||||||
if agg.TotalQuestions > 0 {
|
if agg.TotalQuestions > 0 {
|
||||||
|
agg.OverallF1 /= float64(agg.TotalQuestions)
|
||||||
agg.OverallHitRate /= float64(agg.TotalQuestions)
|
agg.OverallHitRate /= float64(agg.TotalQuestions)
|
||||||
}
|
}
|
||||||
for _, cat := range agg.ByCategory {
|
for _, cat := range agg.ByCategory {
|
||||||
if cat.ValidF1Count > 0 {
|
|
||||||
cat.F1 /= float64(cat.ValidF1Count)
|
|
||||||
}
|
|
||||||
if cat.QuestionCount > 0 {
|
if cat.QuestionCount > 0 {
|
||||||
|
cat.F1 /= float64(cat.QuestionCount)
|
||||||
cat.HitRate /= float64(cat.QuestionCount)
|
cat.HitRate /= float64(cat.QuestionCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -403,9 +359,7 @@ func printSection(title string, results []EvalResult) {
|
||||||
|
|
||||||
// PrintComparison outputs a human-readable comparison table to stdout.
|
// PrintComparison outputs a human-readable comparison table to stdout.
|
||||||
func PrintComparison(results []EvalResult, llmResults []EvalResult) {
|
func PrintComparison(results []EvalResult, llmResults []EvalResult) {
|
||||||
if len(results) > 0 {
|
printSection("No LLM generation", results)
|
||||||
printSection("No LLM generation", results)
|
|
||||||
}
|
|
||||||
if len(llmResults) > 0 {
|
if len(llmResults) > 0 {
|
||||||
printSection("With LLM", llmResults)
|
printSection("With LLM", llmResults)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,346 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/seahorse"
|
|
||||||
)
|
|
||||||
|
|
||||||
const answerSystemPrompt = `You are a helpful assistant. Given conversation context, answer the question concisely and accurately. If the answer is not in the context, say "I don't know". Answer in 1-3 sentences maximum.`
|
|
||||||
|
|
||||||
const judgeSystemPrompt = `You are an impartial judge evaluating answer quality.
|
|
||||||
Compare the candidate answer against the reference answer.
|
|
||||||
Consider semantic equivalence — different wording expressing the same meaning should score high.
|
|
||||||
|
|
||||||
Output ONLY a single integer score from 1 to 5:
|
|
||||||
1 = completely wrong or irrelevant
|
|
||||||
2 = partially related but mostly incorrect
|
|
||||||
3 = partially correct, missing key details
|
|
||||||
4 = mostly correct with minor omissions
|
|
||||||
5 = fully correct, semantically equivalent
|
|
||||||
|
|
||||||
Output ONLY the number, nothing else.`
|
|
||||||
|
|
||||||
// generateAnswer asks the LLM to answer a question given retrieved context.
|
|
||||||
func generateAnswer(ctx context.Context, client *LLMClient, contextText, question string) (string, error) {
|
|
||||||
// Truncate context to avoid exceeding model limits while preserving valid UTF-8.
|
|
||||||
contextRunes := []rune(contextText)
|
|
||||||
if len(contextRunes) > 6000 {
|
|
||||||
contextText = string(contextRunes[:6000]) + "\n... [truncated]"
|
|
||||||
}
|
|
||||||
|
|
||||||
userPrompt := fmt.Sprintf("## Conversation Context\n\n%s\n\n## Question\n\n%s", contextText, question)
|
|
||||||
return client.Complete(ctx, answerSystemPrompt, userPrompt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// scoreRe matches the first standalone integer 1-5 in the judge response.
|
|
||||||
var scoreRe = regexp.MustCompile(`\b([1-5])\b`)
|
|
||||||
|
|
||||||
// judgeAnswer asks the LLM to score the candidate answer vs the gold answer.
|
|
||||||
// Returns a score from 0.0 to 1.0, or -1.0 on parse failure.
|
|
||||||
func judgeAnswer(
|
|
||||||
ctx context.Context,
|
|
||||||
judgeClient *LLMClient,
|
|
||||||
question, goldAnswer, candidateAnswer string,
|
|
||||||
) (float64, error) {
|
|
||||||
userPrompt := fmt.Sprintf(
|
|
||||||
"Question: %s\n\nReference Answer: %s\n\nCandidate Answer: %s\n\nScore:",
|
|
||||||
question, goldAnswer, candidateAnswer,
|
|
||||||
)
|
|
||||||
|
|
||||||
response, err := judgeClient.Complete(ctx, judgeSystemPrompt, userPrompt)
|
|
||||||
if err != nil {
|
|
||||||
return -1.0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
response = strings.TrimSpace(response)
|
|
||||||
if m := scoreRe.FindStringSubmatch(response); len(m) == 2 {
|
|
||||||
score, _ := strconv.Atoi(m[1])
|
|
||||||
return float64(score-1) / 4.0, nil // Normalize 1-5 to 0.0-1.0
|
|
||||||
}
|
|
||||||
log.Printf("WARNING: could not parse judge score from: %q, returning -1", response)
|
|
||||||
return -1.0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// qaWork describes one QA evaluation unit.
|
|
||||||
type qaWork struct {
|
|
||||||
sampleID string
|
|
||||||
qaIndex int
|
|
||||||
globalIndex int
|
|
||||||
totalQA int
|
|
||||||
qa *LocomoQA
|
|
||||||
contextText string
|
|
||||||
sample *LocomoSample
|
|
||||||
}
|
|
||||||
|
|
||||||
// qaResult collects one QA evaluation output.
|
|
||||||
type qaResultOut struct {
|
|
||||||
index int // position in the flat QA list for ordering
|
|
||||||
result QAResult
|
|
||||||
answer string
|
|
||||||
score float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// evalQAWorker processes a single QA item: generate answer + judge score.
|
|
||||||
func evalQAWorker(
|
|
||||||
ctx context.Context,
|
|
||||||
w qaWork,
|
|
||||||
answerClient, judgeClient *LLMClient,
|
|
||||||
logPrefix string,
|
|
||||||
) qaResultOut {
|
|
||||||
llmAnswer, err := generateAnswer(ctx, answerClient, w.contextText, w.qa.Question)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("WARN: LLM generation failed for sample %s Q%d: %v", w.sampleID, w.qaIndex, err)
|
|
||||||
llmAnswer = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
score := -1.0
|
|
||||||
if llmAnswer != "" {
|
|
||||||
score, err = judgeAnswer(ctx, judgeClient, w.qa.Question, w.qa.AnswerString(), llmAnswer)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("WARN: LLM judge failed for sample %s Q%d: %v", w.sampleID, w.qaIndex, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hitRate := RecallHitRate(w.qa.Evidence, w.sample, w.contextText)
|
|
||||||
|
|
||||||
log.Printf("[%s] sample=%s q=%d/%d score=%.2f answer=%q",
|
|
||||||
logPrefix, w.sampleID, w.globalIndex, w.totalQA, score, truncateStr(llmAnswer, 80))
|
|
||||||
|
|
||||||
return qaResultOut{
|
|
||||||
index: w.globalIndex,
|
|
||||||
result: QAResult{
|
|
||||||
Question: w.qa.Question,
|
|
||||||
Category: w.qa.Category,
|
|
||||||
GoldAnswer: w.qa.AnswerString(),
|
|
||||||
TokenF1: score,
|
|
||||||
HitRate: hitRate,
|
|
||||||
},
|
|
||||||
answer: llmAnswer,
|
|
||||||
score: score,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// EvalLegacyLLM evaluates legacy store using LLM generation + LLM-as-Judge.
|
|
||||||
func EvalLegacyLLM(
|
|
||||||
ctx context.Context,
|
|
||||||
samples []LocomoSample,
|
|
||||||
legacy *LegacyStore,
|
|
||||||
budgetTokens int,
|
|
||||||
answerClient, judgeClient *LLMClient,
|
|
||||||
concurrency int,
|
|
||||||
) []EvalResult {
|
|
||||||
if concurrency < 1 {
|
|
||||||
concurrency = 1
|
|
||||||
}
|
|
||||||
totalQA := countTotalQA(samples)
|
|
||||||
results := make([]EvalResult, 0, len(samples))
|
|
||||||
|
|
||||||
for si := range samples {
|
|
||||||
sample := &samples[si]
|
|
||||||
history := legacy.GetHistory(sample.SampleID)
|
|
||||||
|
|
||||||
allContent := make([]string, 0, len(history))
|
|
||||||
for _, msg := range history {
|
|
||||||
allContent = append(allContent, msg.Content)
|
|
||||||
}
|
|
||||||
|
|
||||||
truncated, _ := BudgetTruncate(allContent, budgetTokens)
|
|
||||||
contextText := StringListToContent(truncated)
|
|
||||||
|
|
||||||
qaResults := make([]QAResult, len(sample.QA))
|
|
||||||
|
|
||||||
if concurrency <= 1 {
|
|
||||||
for qi := range sample.QA {
|
|
||||||
out := evalQAWorker(ctx, qaWork{
|
|
||||||
sampleID: sample.SampleID, qaIndex: qi,
|
|
||||||
globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA,
|
|
||||||
qa: &sample.QA[qi], contextText: contextText, sample: sample,
|
|
||||||
}, answerClient, judgeClient, "legacy-llm")
|
|
||||||
qaResults[qi] = out.result
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sem := make(chan struct{}, concurrency)
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for qi := range sample.QA {
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
sem <- struct{}{}
|
|
||||||
defer func() { <-sem }()
|
|
||||||
out := evalQAWorker(ctx, qaWork{
|
|
||||||
sampleID: sample.SampleID, qaIndex: qi,
|
|
||||||
globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA,
|
|
||||||
qa: &sample.QA[qi], contextText: contextText, sample: sample,
|
|
||||||
}, answerClient, judgeClient, "legacy-llm")
|
|
||||||
qaResults[qi] = out.result // safe: each goroutine writes distinct index
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
results = append(results, EvalResult{
|
|
||||||
Mode: "legacy-llm",
|
|
||||||
SampleID: sample.SampleID,
|
|
||||||
QAResults: qaResults,
|
|
||||||
Agg: aggregateMetrics(qaResults),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildSeahorseContext retrieves context for a seahorse QA item.
|
|
||||||
func buildSeahorseContext(
|
|
||||||
ctx context.Context,
|
|
||||||
ir *SeahorseIngestResult,
|
|
||||||
sample *LocomoSample,
|
|
||||||
qa *LocomoQA,
|
|
||||||
budgetTokens int,
|
|
||||||
) string {
|
|
||||||
store := ir.Engine.GetRetrieval().Store()
|
|
||||||
retrieval := ir.Engine.GetRetrieval()
|
|
||||||
convID := ir.ConvMap[sample.SampleID]
|
|
||||||
|
|
||||||
keywords := ExtractKeywords(qa.Question)
|
|
||||||
bestRank := map[int64]float64{}
|
|
||||||
for _, kw := range keywords {
|
|
||||||
searchResults, err := store.SearchMessages(ctx, seahorse.SearchInput{
|
|
||||||
Pattern: kw,
|
|
||||||
ConversationID: convID,
|
|
||||||
Limit: 20,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, sr := range searchResults {
|
|
||||||
if sr.MessageID > 0 {
|
|
||||||
if prev, ok := bestRank[sr.MessageID]; !ok || sr.Rank < prev {
|
|
||||||
bestRank[sr.MessageID] = sr.Rank
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
messageIDs := make([]int64, 0, len(bestRank))
|
|
||||||
for id := range bestRank {
|
|
||||||
messageIDs = append(messageIDs, id)
|
|
||||||
}
|
|
||||||
sort.Slice(messageIDs, func(i, j int) bool {
|
|
||||||
return bestRank[messageIDs[i]] < bestRank[messageIDs[j]]
|
|
||||||
})
|
|
||||||
|
|
||||||
var contentParts []string
|
|
||||||
if len(messageIDs) > 0 {
|
|
||||||
expandResult, err := retrieval.ExpandMessages(ctx, messageIDs)
|
|
||||||
if err == nil {
|
|
||||||
for _, msg := range expandResult.Messages {
|
|
||||||
contentParts = append(contentParts, msg.Content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(contentParts) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
truncated, _ := BudgetTruncate(contentParts, budgetTokens)
|
|
||||||
return StringListToContent(truncated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EvalSeahorseLLM evaluates seahorse retrieval using LLM generation + LLM-as-Judge.
|
|
||||||
func EvalSeahorseLLM(
|
|
||||||
ctx context.Context,
|
|
||||||
samples []LocomoSample,
|
|
||||||
ir *SeahorseIngestResult,
|
|
||||||
budgetTokens int,
|
|
||||||
answerClient, judgeClient *LLMClient,
|
|
||||||
concurrency int,
|
|
||||||
) []EvalResult {
|
|
||||||
if concurrency < 1 {
|
|
||||||
concurrency = 1
|
|
||||||
}
|
|
||||||
totalQA := countTotalQA(samples)
|
|
||||||
results := make([]EvalResult, 0, len(samples))
|
|
||||||
|
|
||||||
for si := range samples {
|
|
||||||
sample := &samples[si]
|
|
||||||
if _, ok := ir.ConvMap[sample.SampleID]; !ok {
|
|
||||||
log.Printf("WARN: no conversation ID for sample %s", sample.SampleID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
qaResults := make([]QAResult, len(sample.QA))
|
|
||||||
|
|
||||||
evalOne := func(qi int) {
|
|
||||||
qa := &sample.QA[qi]
|
|
||||||
contextText := buildSeahorseContext(ctx, ir, sample, qa, budgetTokens)
|
|
||||||
if contextText == "" {
|
|
||||||
qaResults[qi] = QAResult{
|
|
||||||
Question: qa.Question,
|
|
||||||
Category: qa.Category,
|
|
||||||
GoldAnswer: qa.AnswerString(),
|
|
||||||
TokenF1: 0.0,
|
|
||||||
HitRate: 0.0,
|
|
||||||
}
|
|
||||||
log.Printf("[seahorse-llm] sample=%s q=%d/%d score=0.00 answer=(no context)",
|
|
||||||
sample.SampleID, si*len(sample.QA)+qi+1, totalQA)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
out := evalQAWorker(ctx, qaWork{
|
|
||||||
sampleID: sample.SampleID, qaIndex: qi,
|
|
||||||
globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA,
|
|
||||||
qa: qa, contextText: contextText, sample: sample,
|
|
||||||
}, answerClient, judgeClient, "seahorse-llm")
|
|
||||||
qaResults[qi] = out.result
|
|
||||||
}
|
|
||||||
|
|
||||||
if concurrency <= 1 {
|
|
||||||
for qi := range sample.QA {
|
|
||||||
evalOne(qi)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sem := make(chan struct{}, concurrency)
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for qi := range sample.QA {
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
sem <- struct{}{}
|
|
||||||
defer func() { <-sem }()
|
|
||||||
evalOne(qi)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
results = append(results, EvalResult{
|
|
||||||
Mode: "seahorse-llm",
|
|
||||||
SampleID: sample.SampleID,
|
|
||||||
QAResults: qaResults,
|
|
||||||
Agg: aggregateMetrics(qaResults),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
func countTotalQA(samples []LocomoSample) int {
|
|
||||||
n := 0
|
|
||||||
for i := range samples {
|
|
||||||
n += len(samples[i].QA)
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncateStr(s string, maxLen int) string {
|
|
||||||
s = strings.ReplaceAll(s, "\n", " ")
|
|
||||||
runes := []rune(s)
|
|
||||||
if len(runes) > maxLen {
|
|
||||||
return string(runes[:maxLen]) + "..."
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
@ -102,81 +102,3 @@ func TestComputeModeAgg(t *testing.T) {
|
||||||
t.Errorf("TotalQuestions = %d, want 10", got.TotalQuestions)
|
t.Errorf("TotalQuestions = %d, want 10", got.TotalQuestions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAggregateMetricsSentinel(t *testing.T) {
|
|
||||||
qa := []QAResult{
|
|
||||||
{Category: 1, TokenF1: 0.8, HitRate: 0.5},
|
|
||||||
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
|
|
||||||
{Category: 1, TokenF1: 0.4, HitRate: 0.7},
|
|
||||||
}
|
|
||||||
agg := aggregateMetrics(qa)
|
|
||||||
|
|
||||||
if agg.ValidF1Count != 2 {
|
|
||||||
t.Errorf("ValidF1Count = %d, want 2", agg.ValidF1Count)
|
|
||||||
}
|
|
||||||
if agg.TotalQuestions != 3 {
|
|
||||||
t.Errorf("TotalQuestions = %d, want 3", agg.TotalQuestions)
|
|
||||||
}
|
|
||||||
wantF1 := (0.8 + 0.4) / 2.0
|
|
||||||
if math.Abs(agg.OverallF1-wantF1) > 1e-9 {
|
|
||||||
t.Errorf("OverallF1 = %.6f, want %.6f", agg.OverallF1, wantF1)
|
|
||||||
}
|
|
||||||
wantHR := (0.5 + 0.3 + 0.7) / 3.0
|
|
||||||
if math.Abs(agg.OverallHitRate-wantHR) > 1e-9 {
|
|
||||||
t.Errorf("OverallHitRate = %.6f, want %.6f", agg.OverallHitRate, wantHR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAggregateMetricsAllSentinel(t *testing.T) {
|
|
||||||
qa := []QAResult{
|
|
||||||
{Category: 1, TokenF1: -1.0, HitRate: 0.5},
|
|
||||||
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
|
|
||||||
}
|
|
||||||
agg := aggregateMetrics(qa)
|
|
||||||
|
|
||||||
if agg.ValidF1Count != 0 {
|
|
||||||
t.Errorf("ValidF1Count = %d, want 0", agg.ValidF1Count)
|
|
||||||
}
|
|
||||||
if agg.OverallF1 != 0 {
|
|
||||||
t.Errorf("OverallF1 = %.6f, want 0", agg.OverallF1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComputeModeAggSentinelWeighting(t *testing.T) {
|
|
||||||
results := []EvalResult{
|
|
||||||
{
|
|
||||||
Mode: "test",
|
|
||||||
SampleID: "s1",
|
|
||||||
QAResults: []QAResult{
|
|
||||||
{Category: 1, TokenF1: 0.8, HitRate: 0.5},
|
|
||||||
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Mode: "test",
|
|
||||||
SampleID: "s2",
|
|
||||||
QAResults: []QAResult{
|
|
||||||
{Category: 1, TokenF1: 0.4, HitRate: 0.6},
|
|
||||||
{Category: 1, TokenF1: 0.6, HitRate: 0.8},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for i := range results {
|
|
||||||
results[i].Agg = aggregateMetrics(results[i].QAResults)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := computeModeAgg(results)
|
|
||||||
|
|
||||||
// s1: ValidF1Count=1, F1=0.8; s2: ValidF1Count=2, F1=0.5
|
|
||||||
// Weighted: (0.8*1 + 0.5*2) / 3 = 1.8/3 = 0.6
|
|
||||||
wantF1 := 0.6
|
|
||||||
if math.Abs(got.OverallF1-wantF1) > 1e-9 {
|
|
||||||
t.Errorf("OverallF1 = %.6f, want %.6f", got.OverallF1, wantF1)
|
|
||||||
}
|
|
||||||
if got.ValidF1Count != 3 {
|
|
||||||
t.Errorf("ValidF1Count = %d, want 3", got.ValidF1Count)
|
|
||||||
}
|
|
||||||
if got.TotalQuestions != 4 {
|
|
||||||
t.Errorf("TotalQuestions = %d, want 4", got.TotalQuestions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LLMClient wraps an OpenAI-compatible chat completion endpoint.
|
|
||||||
type LLMClient struct {
|
|
||||||
BaseURL string
|
|
||||||
Model string
|
|
||||||
APIKey string
|
|
||||||
NoThinking bool // send chat_template_kwargs to disable thinking (llama.cpp specific)
|
|
||||||
MaxRetries int // max retry attempts for transient errors (0 = no retry)
|
|
||||||
Client *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// LLMClientOptions configures the LLM client.
|
|
||||||
type LLMClientOptions struct {
|
|
||||||
BaseURL string
|
|
||||||
Model string
|
|
||||||
APIKey string
|
|
||||||
Timeout time.Duration
|
|
||||||
NoThinking bool
|
|
||||||
MaxRetries int // max retry attempts (default 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewLLMClient creates a client for an OpenAI-compatible chat completion API.
|
|
||||||
func NewLLMClient(opts LLMClientOptions) *LLMClient {
|
|
||||||
if opts.Timeout == 0 {
|
|
||||||
opts.Timeout = 120 * time.Second
|
|
||||||
}
|
|
||||||
maxRetries := opts.MaxRetries
|
|
||||||
if maxRetries < 0 {
|
|
||||||
maxRetries = 3
|
|
||||||
}
|
|
||||||
return &LLMClient{
|
|
||||||
BaseURL: strings.TrimRight(opts.BaseURL, "/"),
|
|
||||||
Model: opts.Model,
|
|
||||||
APIKey: opts.APIKey,
|
|
||||||
NoThinking: opts.NoThinking,
|
|
||||||
MaxRetries: maxRetries,
|
|
||||||
Client: &http.Client{
|
|
||||||
Timeout: opts.Timeout,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type chatRequest struct {
|
|
||||||
Model string `json:"model"`
|
|
||||||
Messages []chatMessage `json:"messages"`
|
|
||||||
Temperature float64 `json:"temperature"`
|
|
||||||
MaxTokens int `json:"max_tokens"`
|
|
||||||
ChatTemplateKwargs map[string]any `json:"chat_template_kwargs,omitempty"` // llama.cpp
|
|
||||||
Think *bool `json:"think,omitempty"` // Ollama
|
|
||||||
Thinking map[string]any `json:"thinking,omitempty"` // GLM (智谱)
|
|
||||||
}
|
|
||||||
|
|
||||||
type chatMessage struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type chatResponse struct {
|
|
||||||
Choices []struct {
|
|
||||||
Message struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
|
||||||
} `json:"message"`
|
|
||||||
} `json:"choices"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete sends a chat completion request and returns the assistant's reply.
|
|
||||||
func (c *LLMClient) Complete(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
|
|
||||||
sysContent := systemPrompt
|
|
||||||
if c.NoThinking && sysContent != "" {
|
|
||||||
// Prepend /no_think tag — works with Ollama /v1 endpoint and
|
|
||||||
// Qwen chat templates where the JSON think field is ignored.
|
|
||||||
sysContent = "/no_think\n" + sysContent
|
|
||||||
}
|
|
||||||
messages := []chatMessage{}
|
|
||||||
if sysContent != "" {
|
|
||||||
messages = append(messages, chatMessage{Role: "system", Content: sysContent})
|
|
||||||
}
|
|
||||||
messages = append(messages, chatMessage{Role: "user", Content: userPrompt})
|
|
||||||
|
|
||||||
body := chatRequest{
|
|
||||||
Model: c.Model,
|
|
||||||
Messages: messages,
|
|
||||||
Temperature: 0.1,
|
|
||||||
MaxTokens: 512,
|
|
||||||
}
|
|
||||||
if c.NoThinking {
|
|
||||||
// llama.cpp: chat_template_kwargs
|
|
||||||
body.ChatTemplateKwargs = map[string]any{
|
|
||||||
"enable_thinking": false,
|
|
||||||
}
|
|
||||||
// Ollama (0.9+): think field
|
|
||||||
thinkFalse := false
|
|
||||||
body.Think = &thinkFalse
|
|
||||||
// GLM (智谱): thinking field
|
|
||||||
body.Thinking = map[string]any{
|
|
||||||
"type": "disabled",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBody, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("marshal request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint := strings.TrimRight(c.BaseURL, "/") + "/chat/completions"
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(jsonBody))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
if c.APIKey != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
var respBody []byte
|
|
||||||
var lastErr error
|
|
||||||
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
|
|
||||||
if attempt > 0 {
|
|
||||||
backoff := time.Duration(1<<(attempt-1)) * time.Second // 1s, 2s, 4s, ...
|
|
||||||
log.Printf("LLM retry %d/%d after %v: %v", attempt, c.MaxRetries, backoff, lastErr)
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return "", ctx.Err()
|
|
||||||
case <-time.After(backoff):
|
|
||||||
}
|
|
||||||
// Rebuild request (body reader is consumed)
|
|
||||||
req, err = http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(jsonBody))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
if c.APIKey != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp *http.Response
|
|
||||||
resp, lastErr = c.Client.Do(req)
|
|
||||||
if lastErr != nil {
|
|
||||||
continue // network/timeout error → retry
|
|
||||||
}
|
|
||||||
|
|
||||||
respBody, lastErr = io.ReadAll(resp.Body)
|
|
||||||
resp.Body.Close()
|
|
||||||
if lastErr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
|
|
||||||
lastErr = fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
|
|
||||||
continue // rate limit or server error → retry
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
lastErr = nil
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if lastErr != nil {
|
|
||||||
return "", fmt.Errorf("after %d retries: %w", c.MaxRetries, lastErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
var chatResp chatResponse
|
|
||||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
|
||||||
return "", fmt.Errorf("parse response: %w", err)
|
|
||||||
}
|
|
||||||
if len(chatResp.Choices) == 0 {
|
|
||||||
return "", fmt.Errorf("no choices in response")
|
|
||||||
}
|
|
||||||
content := strings.TrimSpace(chatResp.Choices[0].Message.Content)
|
|
||||||
// Strip any residual <think>...</think> blocks
|
|
||||||
if idx := strings.Index(content, "</think>"); idx >= 0 {
|
|
||||||
content = strings.TrimSpace(content[idx+len("</think>"):])
|
|
||||||
}
|
|
||||||
// Fallback: GLM/DeepSeek put thinking output in reasoning_content when thinking is enabled
|
|
||||||
if content == "" && chatResp.Choices[0].Message.ReasoningContent != "" {
|
|
||||||
content = strings.TrimSpace(chatResp.Choices[0].Message.ReasoningContent)
|
|
||||||
}
|
|
||||||
if content == "" {
|
|
||||||
return "", fmt.Errorf("empty LLM response")
|
|
||||||
}
|
|
||||||
return content, nil
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,6 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
|
@ -16,22 +15,10 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
flagData string
|
flagData string
|
||||||
flagOut string
|
flagOut string
|
||||||
flagMode string
|
flagMode string
|
||||||
flagBudget int
|
flagBudget int
|
||||||
flagEvalMode string
|
|
||||||
flagAPIBase string
|
|
||||||
flagAPIKey string
|
|
||||||
flagModel string
|
|
||||||
flagNoThinking bool
|
|
||||||
flagLimit int
|
|
||||||
flagTimeout int
|
|
||||||
flagRetries int
|
|
||||||
flagJudgeModel string
|
|
||||||
flagJudgeAPIBase string
|
|
||||||
flagJudgeAPIKey string
|
|
||||||
flagConcurrency int
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -61,22 +48,6 @@ func main() {
|
||||||
evalCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
evalCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||||
evalCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to evaluate: legacy, seahorse, or all")
|
evalCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to evaluate: legacy, seahorse, or all")
|
||||||
evalCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
evalCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
||||||
evalCmd.Flags().
|
|
||||||
StringVar(&flagEvalMode, "eval-mode", "token", "evaluation mode: token (direct match) or llm (LLM-as-Judge)")
|
|
||||||
evalCmd.Flags().
|
|
||||||
StringVar(&flagAPIBase, "api-base", "", "API base URL with version path, e.g. http://host/v1 (default: http://127.0.0.1:8080/v1, env: MEMBENCH_API_BASE)")
|
|
||||||
evalCmd.Flags().StringVar(&flagAPIKey, "api-key", "", "API key for the LLM endpoint (env: MEMBENCH_API_KEY)")
|
|
||||||
evalCmd.Flags().StringVar(&flagModel, "model", "", "model name for LLM eval (env: MEMBENCH_MODEL)")
|
|
||||||
evalCmd.Flags().
|
|
||||||
BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)")
|
|
||||||
evalCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)")
|
|
||||||
evalCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests")
|
|
||||||
evalCmd.Flags().IntVar(&flagRetries, "retries", 3, "max retry attempts for transient LLM errors (timeout/5xx/429)")
|
|
||||||
evalCmd.Flags().StringVar(&flagJudgeModel, "judge-model", "", "model for judge scoring (defaults to --model)")
|
|
||||||
evalCmd.Flags().
|
|
||||||
StringVar(&flagJudgeAPIBase, "judge-api-base", "", "API base URL for judge model (defaults to --api-base)")
|
|
||||||
evalCmd.Flags().StringVar(&flagJudgeAPIKey, "judge-api-key", "", "API key for judge model (defaults to --api-key)")
|
|
||||||
evalCmd.Flags().IntVar(&flagConcurrency, "concurrency", 1, "number of concurrent QA evaluations")
|
|
||||||
|
|
||||||
reportCmd := &cobra.Command{
|
reportCmd := &cobra.Command{
|
||||||
Use: "report",
|
Use: "report",
|
||||||
|
|
@ -94,22 +65,6 @@ func main() {
|
||||||
runCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
runCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||||
runCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to run: legacy, seahorse, or all")
|
runCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to run: legacy, seahorse, or all")
|
||||||
runCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
runCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
||||||
runCmd.Flags().
|
|
||||||
StringVar(&flagEvalMode, "eval-mode", "token", "evaluation mode: token (direct match) or llm (LLM-as-Judge)")
|
|
||||||
runCmd.Flags().
|
|
||||||
StringVar(&flagAPIBase, "api-base", "", "API base URL with version path, e.g. http://host/v1 (default: http://127.0.0.1:8080/v1, env: MEMBENCH_API_BASE)")
|
|
||||||
runCmd.Flags().StringVar(&flagAPIKey, "api-key", "", "API key for the LLM endpoint (env: MEMBENCH_API_KEY)")
|
|
||||||
runCmd.Flags().StringVar(&flagModel, "model", "", "model name for LLM eval (env: MEMBENCH_MODEL)")
|
|
||||||
runCmd.Flags().
|
|
||||||
BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)")
|
|
||||||
runCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)")
|
|
||||||
runCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests")
|
|
||||||
runCmd.Flags().IntVar(&flagRetries, "retries", 3, "max retry attempts for transient LLM errors (timeout/5xx/429)")
|
|
||||||
runCmd.Flags().StringVar(&flagJudgeModel, "judge-model", "", "model for judge scoring (defaults to --model)")
|
|
||||||
runCmd.Flags().
|
|
||||||
StringVar(&flagJudgeAPIBase, "judge-api-base", "", "API base URL for judge model (defaults to --api-base)")
|
|
||||||
runCmd.Flags().StringVar(&flagJudgeAPIKey, "judge-api-key", "", "API key for judge model (defaults to --api-key)")
|
|
||||||
runCmd.Flags().IntVar(&flagConcurrency, "concurrency", 1, "number of concurrent QA evaluations")
|
|
||||||
|
|
||||||
rootCmd.AddCommand(ingestCmd, evalCmd, reportCmd, runCmd)
|
rootCmd.AddCommand(ingestCmd, evalCmd, reportCmd, runCmd)
|
||||||
|
|
||||||
|
|
@ -181,50 +136,7 @@ func runEval(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
log.Printf("Loaded %d samples", len(samples))
|
log.Printf("Loaded %d samples", len(samples))
|
||||||
|
|
||||||
if flagLimit > 0 {
|
var allResults []EvalResult
|
||||||
for i := range samples {
|
|
||||||
if len(samples[i].QA) > flagLimit {
|
|
||||||
samples[i].QA = samples[i].QA[:flagLimit]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Printf("Limited to %d QA per sample", flagLimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
evalMode := strings.ToLower(strings.TrimSpace(flagEvalMode))
|
|
||||||
var useLLM bool
|
|
||||||
switch evalMode {
|
|
||||||
case "token":
|
|
||||||
useLLM = false
|
|
||||||
case "llm":
|
|
||||||
useLLM = true
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("invalid --eval-mode %q: must be token or llm", flagEvalMode)
|
|
||||||
}
|
|
||||||
var answerClient, judgeClient *LLMClient
|
|
||||||
if useLLM {
|
|
||||||
opts, err := buildLLMOptions()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
answerClient = NewLLMClient(opts)
|
|
||||||
judgeClient = answerClient // default: same client
|
|
||||||
if flagJudgeModel != "" {
|
|
||||||
jOpts := opts // copy base settings
|
|
||||||
jOpts.Model = flagJudgeModel
|
|
||||||
if flagJudgeAPIBase != "" {
|
|
||||||
jOpts.BaseURL = flagJudgeAPIBase
|
|
||||||
}
|
|
||||||
if flagJudgeAPIKey != "" {
|
|
||||||
jOpts.APIKey = flagJudgeAPIKey
|
|
||||||
}
|
|
||||||
judgeClient = NewLLMClient(jOpts)
|
|
||||||
log.Printf("Judge model: model=%s base=%s no-thinking=%v", jOpts.Model, jOpts.BaseURL, jOpts.NoThinking)
|
|
||||||
}
|
|
||||||
log.Printf("LLM eval mode: model=%s base=%s no-thinking=%v concurrency=%d",
|
|
||||||
opts.Model, opts.BaseURL, opts.NoThinking, flagConcurrency)
|
|
||||||
}
|
|
||||||
|
|
||||||
var tokenResults, llmResults []EvalResult
|
|
||||||
|
|
||||||
for _, mode := range modes {
|
for _, mode := range modes {
|
||||||
switch mode {
|
switch mode {
|
||||||
|
|
@ -233,34 +145,21 @@ func runEval(cmd *cobra.Command, args []string) error {
|
||||||
for i := range samples {
|
for i := range samples {
|
||||||
legacy.IngestSample(&samples[i])
|
legacy.IngestSample(&samples[i])
|
||||||
}
|
}
|
||||||
if useLLM {
|
results := EvalLegacy(ctx, samples, legacy, flagBudget)
|
||||||
results := EvalLegacyLLM(ctx, samples, legacy, flagBudget, answerClient, judgeClient, flagConcurrency)
|
allResults = append(allResults, results...)
|
||||||
llmResults = append(llmResults, results...)
|
log.Printf("legacy: evaluated %d samples", len(results))
|
||||||
log.Printf("legacy-llm: evaluated %d samples", len(results))
|
|
||||||
} else {
|
|
||||||
results := EvalLegacy(ctx, samples, legacy, flagBudget)
|
|
||||||
tokenResults = append(tokenResults, results...)
|
|
||||||
log.Printf("legacy: evaluated %d samples", len(results))
|
|
||||||
}
|
|
||||||
case "seahorse":
|
case "seahorse":
|
||||||
dbPath := filepath.Join(flagOut, "seahorse.db")
|
dbPath := filepath.Join(flagOut, "seahorse.db")
|
||||||
ir, err := IngestSeahorse(ctx, samples, dbPath)
|
ir, err := IngestSeahorse(ctx, samples, dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ingest seahorse: %w", err)
|
return fmt.Errorf("ingest seahorse: %w", err)
|
||||||
}
|
}
|
||||||
if useLLM {
|
results := EvalSeahorse(ctx, samples, ir, flagBudget)
|
||||||
results := EvalSeahorseLLM(ctx, samples, ir, flagBudget, answerClient, judgeClient, flagConcurrency)
|
allResults = append(allResults, results...)
|
||||||
llmResults = append(llmResults, results...)
|
log.Printf("seahorse: evaluated %d samples", len(results))
|
||||||
log.Printf("seahorse-llm: evaluated %d samples", len(results))
|
|
||||||
} else {
|
|
||||||
results := EvalSeahorse(ctx, samples, ir, flagBudget)
|
|
||||||
tokenResults = append(tokenResults, results...)
|
|
||||||
log.Printf("seahorse: evaluated %d samples", len(results))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allResults := append(tokenResults, llmResults...)
|
|
||||||
if err := SaveResults(allResults, flagOut); err != nil {
|
if err := SaveResults(allResults, flagOut); err != nil {
|
||||||
return fmt.Errorf("save results: %w", err)
|
return fmt.Errorf("save results: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -268,7 +167,7 @@ func runEval(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("save aggregated: %w", err)
|
return fmt.Errorf("save aggregated: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
PrintComparison(tokenResults, llmResults)
|
PrintComparison(allResults, nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,62 +199,10 @@ func runReport(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("no eval results found in %s", flagOut)
|
return fmt.Errorf("no eval results found in %s", flagOut)
|
||||||
}
|
}
|
||||||
|
|
||||||
var tokenResults, llmResults []EvalResult
|
PrintComparison(allResults, nil)
|
||||||
for _, r := range allResults {
|
|
||||||
if strings.HasSuffix(r.Mode, "-llm") {
|
|
||||||
llmResults = append(llmResults, r)
|
|
||||||
} else {
|
|
||||||
tokenResults = append(tokenResults, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PrintComparison(tokenResults, llmResults)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runAll(cmd *cobra.Command, args []string) error {
|
func runAll(cmd *cobra.Command, args []string) error {
|
||||||
return runEval(cmd, args)
|
return runEval(cmd, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// envOrFlag returns the flag value if non-empty, otherwise falls back to the
|
|
||||||
// environment variable.
|
|
||||||
func envOrFlag(flag, envKey string) string {
|
|
||||||
if flag != "" {
|
|
||||||
return flag
|
|
||||||
}
|
|
||||||
return os.Getenv(envKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildLLMOptions resolves LLM client configuration from flags and environment
|
|
||||||
// variables. Flag values take precedence over environment variables.
|
|
||||||
//
|
|
||||||
// Environment variables:
|
|
||||||
//
|
|
||||||
// MEMBENCH_API_BASE – OpenAI-compatible base URL (default http://127.0.0.1:8080/v1)
|
|
||||||
// MEMBENCH_API_KEY – Bearer token for the endpoint
|
|
||||||
// MEMBENCH_MODEL – Model name to send in the request
|
|
||||||
func buildLLMOptions() (LLMClientOptions, error) {
|
|
||||||
base := envOrFlag(flagAPIBase, "MEMBENCH_API_BASE")
|
|
||||||
if base == "" {
|
|
||||||
base = "http://127.0.0.1:8080/v1"
|
|
||||||
}
|
|
||||||
model := envOrFlag(flagModel, "MEMBENCH_MODEL")
|
|
||||||
if model == "" {
|
|
||||||
return LLMClientOptions{}, fmt.Errorf(
|
|
||||||
"--model or MEMBENCH_MODEL is required for LLM eval mode",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
apiKey := envOrFlag(flagAPIKey, "MEMBENCH_API_KEY")
|
|
||||||
|
|
||||||
if flagTimeout <= 0 {
|
|
||||||
return LLMClientOptions{}, fmt.Errorf("--timeout must be > 0, got %d", flagTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
return LLMClientOptions{
|
|
||||||
BaseURL: base,
|
|
||||||
Model: model,
|
|
||||||
APIKey: apiKey,
|
|
||||||
NoThinking: flagNoThinking,
|
|
||||||
Timeout: time.Duration(flagTimeout) * time.Second,
|
|
||||||
MaxRetries: flagRetries,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
|
||||||
69
cmd/picoclaw-launcher-tui/README.md
Normal file
69
cmd/picoclaw-launcher-tui/README.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Picoclaw Launcher TUI
|
||||||
|
|
||||||
|
This directory contains the terminal-based TUI launcher for `picoclaw`.
|
||||||
|
It provides a lightweight, terminal-native user interface for managing, configuring, and interacting with the core `picoclaw` engine, without requiring a web browser or graphical environment.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The TUI launcher is implemented purely in Go with no external runtime dependencies:
|
||||||
|
* **`main.go`**: Application entry point, handles initialization and main event loop
|
||||||
|
* **`ui/`**: TUI interface components built on tview + tcell framework:
|
||||||
|
- `home.go`: Main dashboard with navigation menu
|
||||||
|
- `schemes.go`: AI model scheme management
|
||||||
|
- `users.go`: User and API key management for model providers
|
||||||
|
- `channels.go`: Communication channel (Telegram/Discord/WeChat etc.) configuration editor
|
||||||
|
- `gateway.go`: PicoClaw gateway daemon lifecycle management (start/stop/status)
|
||||||
|
- `app.go`: Core TUI application framework and navigation logic
|
||||||
|
- `models.go`: Data structures and state management
|
||||||
|
* **`config/`**: Configuration management layer, integrates with the core picoclaw configuration system
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
* Go 1.25+
|
||||||
|
* Terminal with 256-color support (most modern terminals are compatible)
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
Run the TUI launcher directly in development mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From project root
|
||||||
|
go run ./cmd/picoclaw-launcher-tui
|
||||||
|
|
||||||
|
# Or from this directory
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
Build the standalone TUI launcher binary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From project root (recommended)
|
||||||
|
make build-launcher-tui
|
||||||
|
|
||||||
|
# Output will be at:
|
||||||
|
# build/picoclaw-launcher-tui-<platform>-<arch>
|
||||||
|
# with symlink build/picoclaw-launcher-tui
|
||||||
|
|
||||||
|
# Or build directly from this directory
|
||||||
|
go build -o picoclaw-launcher-tui .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
* 🖥️ Terminal-native interface - works over SSH, on headless servers, and in low-resource environments
|
||||||
|
* ⚙️ AI model scheme and API key management
|
||||||
|
* 📱 Communication channel configuration editor (Telegram/Discord/WeChat etc.)
|
||||||
|
* 🔄 PicoClaw gateway daemon management (start/stop/status monitoring)
|
||||||
|
* 💬 One-click launch of interactive AI chat session
|
||||||
|
* 🎯 Keyboard-first design with intuitive shortcuts
|
||||||
|
|
||||||
|
### Other Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run with custom config file path
|
||||||
|
go run . /path/to/custom/config.json
|
||||||
|
```
|
||||||
236
cmd/picoclaw-launcher-tui/config/config.go
Normal file
236
cmd/picoclaw-launcher-tui/config/config.go
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
// Package config provides types and I/O for ~/.picoclaw/tui.toml.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultConfigPath returns the default path to the tui.toml config file.
|
||||||
|
func DefaultConfigPath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
home = "."
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".picoclaw", "tui.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml.
|
||||||
|
type TUIConfig struct {
|
||||||
|
Version string `toml:"version"`
|
||||||
|
Model Model `toml:"model"`
|
||||||
|
Provider Provider `toml:"provider"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
Type string `toml:"type"` // "provider" (default) | "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
Schemes []Scheme `toml:"schemes"`
|
||||||
|
Users []User `toml:"users"`
|
||||||
|
Current ProviderCurrent `toml:"current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Scheme struct {
|
||||||
|
Name string `toml:"name"` // unique key
|
||||||
|
BaseURL string `toml:"baseURL"` // required
|
||||||
|
Type string `toml:"type"` // "openai-compatible" (default) | "anthropic"
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique
|
||||||
|
Type string `toml:"type"` // "key" (default) | "OAuth"
|
||||||
|
Key string `toml:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderCurrent struct {
|
||||||
|
Scheme string `toml:"scheme"` // references Scheme.Name
|
||||||
|
User string `toml:"user"` // references User.Name where User.Scheme == Scheme
|
||||||
|
Model string `toml:"model"` // from GET <baseURL>/models
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a minimal valid TUIConfig.
|
||||||
|
func DefaultConfig() *TUIConfig {
|
||||||
|
return &TUIConfig{
|
||||||
|
Version: "1.0",
|
||||||
|
Model: Model{Type: "provider"},
|
||||||
|
Provider: Provider{
|
||||||
|
Schemes: []Scheme{},
|
||||||
|
Users: []User{},
|
||||||
|
Current: ProviderCurrent{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads the TUI config from path. Returns a default config if the file does not exist.
|
||||||
|
func Load(path string) (*TUIConfig, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return DefaultConfig(), nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read config file %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if _, err := toml.Decode(string(data), cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDefaults(cfg)
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes cfg to path atomically (safe for flash / SD storage).
|
||||||
|
func Save(path string, cfg *TUIConfig) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create config directory: %w", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc := toml.NewEncoder(&buf)
|
||||||
|
if err := enc.Encode(cfg); err != nil {
|
||||||
|
return fmt.Errorf("failed to encode config: %w", err)
|
||||||
|
}
|
||||||
|
if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write config file %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDefaults(cfg *TUIConfig) {
|
||||||
|
if cfg.Version == "" {
|
||||||
|
cfg.Version = "1.0"
|
||||||
|
}
|
||||||
|
if cfg.Model.Type == "" {
|
||||||
|
cfg.Model.Type = "provider"
|
||||||
|
}
|
||||||
|
for i := range cfg.Provider.Schemes {
|
||||||
|
if cfg.Provider.Schemes[i].Type == "" {
|
||||||
|
cfg.Provider.Schemes[i].Type = "openai-compatible"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range cfg.Provider.Users {
|
||||||
|
if cfg.Provider.Users[i].Type == "" {
|
||||||
|
cfg.Provider.Users[i].Type = "key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemeByName returns the first Scheme whose Name matches, or nil.
|
||||||
|
func (p *Provider) SchemeByName(name string) *Scheme {
|
||||||
|
for i := range p.Schemes {
|
||||||
|
if p.Schemes[i].Name == name {
|
||||||
|
return &p.Schemes[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsersForScheme returns all users whose Scheme field matches schemeName.
|
||||||
|
func (p *Provider) UsersForScheme(schemeName string) []User {
|
||||||
|
var out []User
|
||||||
|
for _, u := range p.Users {
|
||||||
|
if u.Scheme == schemeName {
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json
|
||||||
|
// Adds/replaces a "tui-prefer" model entry and sets it as the default model.
|
||||||
|
// Preserves all other existing fields in the config file unchanged.
|
||||||
|
func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
home = "."
|
||||||
|
}
|
||||||
|
mainConfigPath := filepath.Join(home, ".picoclaw", "config.json")
|
||||||
|
|
||||||
|
var cfg map[string]any
|
||||||
|
if data, readErr := os.ReadFile(mainConfigPath); readErr == nil {
|
||||||
|
if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil {
|
||||||
|
cfg = make(map[string]any)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cfg = make(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := cfg["agents"]; !ok {
|
||||||
|
cfg["agents"] = make(map[string]any)
|
||||||
|
}
|
||||||
|
agents, ok := cfg["agents"].(map[string]any)
|
||||||
|
if ok {
|
||||||
|
if _, ok := agents["defaults"]; !ok {
|
||||||
|
agents["defaults"] = make(map[string]any)
|
||||||
|
}
|
||||||
|
defaults, ok := agents["defaults"].(map[string]any)
|
||||||
|
if ok {
|
||||||
|
defaults["model"] = "tui-prefer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tuiModel := map[string]any{
|
||||||
|
"model_name": "tui-prefer",
|
||||||
|
"model": modelID,
|
||||||
|
"api_key": user.Key,
|
||||||
|
"api_base": scheme.BaseURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
modelList := []any{}
|
||||||
|
if ml, ok := cfg["model_list"].([]any); ok {
|
||||||
|
modelList = ml
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i, m := range modelList {
|
||||||
|
if entry, ok := m.(map[string]any); ok {
|
||||||
|
if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" {
|
||||||
|
modelList[i] = tuiModel
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
modelList = append(modelList, tuiModel)
|
||||||
|
}
|
||||||
|
cfg["model_list"] = modelList
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(mainConfigPath, data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *TUIConfig) CurrentModelLabel() string {
|
||||||
|
cur := cfg.Provider.Current
|
||||||
|
if cur.Model == "" {
|
||||||
|
return "(not configured)"
|
||||||
|
}
|
||||||
|
label := cur.Scheme
|
||||||
|
if label != "" {
|
||||||
|
label += " / "
|
||||||
|
}
|
||||||
|
return label + cur.Model
|
||||||
|
}
|
||||||
48
cmd/picoclaw-launcher-tui/main.go
Normal file
48
cmd/picoclaw-launcher-tui/main.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := tuicfg.DefaultConfigPath()
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
configPath = os.Args[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
configDir := filepath.Dir(configPath)
|
||||||
|
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||||
|
cmd := exec.Command("picoclaw", "onboard")
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
_ = cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := tuicfg.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := ui.New(cfg, configPath)
|
||||||
|
// Bind model selection hook to sync to main config
|
||||||
|
app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) {
|
||||||
|
_ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID)
|
||||||
|
}
|
||||||
|
if err := app.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
325
cmd/picoclaw-launcher-tui/ui/app.go
Normal file
325
cmd/picoclaw-launcher-tui/ui/app.go
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// App is the root TUI application.
|
||||||
|
type App struct {
|
||||||
|
tapp *tview.Application
|
||||||
|
pages *tview.Pages
|
||||||
|
pageStack []string
|
||||||
|
cfg *tuicfg.TUIConfig
|
||||||
|
configPath string
|
||||||
|
pageRefreshFns map[string]func()
|
||||||
|
headerModelTV *tview.TextView
|
||||||
|
modalOpen map[string]bool
|
||||||
|
|
||||||
|
// OnModelSelected is called when a model is selected in the UI.
|
||||||
|
// Can be nil to disable.
|
||||||
|
OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string)
|
||||||
|
|
||||||
|
modelCache map[string][]modelEntry
|
||||||
|
modelCacheMu sync.RWMutex
|
||||||
|
refreshMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheKey returns the map key for a (scheme, user) pair.
|
||||||
|
func cacheKey(schemeName, userName string) string {
|
||||||
|
return fmt.Sprintf("%s/%s", schemeName, userName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedModels returns a defensive copy of the cached model list for a user (may be nil).
|
||||||
|
func (a *App) cachedModels(schemeName, userName string) []modelEntry {
|
||||||
|
a.modelCacheMu.RLock()
|
||||||
|
defer a.modelCacheMu.RUnlock()
|
||||||
|
entries := a.modelCache[cacheKey(schemeName, userName)]
|
||||||
|
return append([]modelEntry(nil), entries...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshModelCache fetches models for every user in the config concurrently.
|
||||||
|
// Serialized by refreshMu so concurrent calls don't race on the cache map.
|
||||||
|
// When all fetches complete it calls onDone via QueueUpdateDraw.
|
||||||
|
func (a *App) refreshModelCache(onDone func()) {
|
||||||
|
go func() {
|
||||||
|
a.refreshMu.Lock()
|
||||||
|
defer a.refreshMu.Unlock()
|
||||||
|
|
||||||
|
users := a.cfg.Provider.Users
|
||||||
|
schemes := a.cfg.Provider.Schemes
|
||||||
|
|
||||||
|
schemeURL := make(map[string]string, len(schemes))
|
||||||
|
for _, s := range schemes {
|
||||||
|
schemeURL[s.Name] = s.BaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, u := range users {
|
||||||
|
baseURL, ok := schemeURL[u.Scheme]
|
||||||
|
if !ok || baseURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if u.Key == "" {
|
||||||
|
a.modelCacheMu.Lock()
|
||||||
|
if a.modelCache == nil {
|
||||||
|
a.modelCache = make(map[string][]modelEntry)
|
||||||
|
}
|
||||||
|
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
|
||||||
|
a.modelCacheMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
bURL := baseURL
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
entries, err := fetchModels(bURL, u.Key)
|
||||||
|
a.modelCacheMu.Lock()
|
||||||
|
if a.modelCache == nil {
|
||||||
|
a.modelCache = make(map[string][]modelEntry)
|
||||||
|
}
|
||||||
|
if err != nil || len(entries) == 0 {
|
||||||
|
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
|
||||||
|
} else {
|
||||||
|
a.modelCache[cacheKey(u.Scheme, u.Name)] = entries
|
||||||
|
}
|
||||||
|
a.modelCacheMu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if onDone != nil {
|
||||||
|
a.tapp.QueueUpdateDraw(onDone)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates and wires up the TUI application.
|
||||||
|
func New(cfg *tuicfg.TUIConfig, configPath string) *App {
|
||||||
|
// Cyberpunk Theme Colors
|
||||||
|
// Dark background
|
||||||
|
tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void
|
||||||
|
tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo
|
||||||
|
tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40)
|
||||||
|
|
||||||
|
// Borders and Titles
|
||||||
|
tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||||
|
tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||||
|
tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
|
||||||
|
|
||||||
|
// Text
|
||||||
|
tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white
|
||||||
|
tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||||
|
tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime
|
||||||
|
tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black
|
||||||
|
tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
|
||||||
|
|
||||||
|
a := &App{
|
||||||
|
tapp: tview.NewApplication(),
|
||||||
|
pages: tview.NewPages(),
|
||||||
|
pageStack: []string{},
|
||||||
|
cfg: cfg,
|
||||||
|
configPath: configPath,
|
||||||
|
pageRefreshFns: make(map[string]func()),
|
||||||
|
modalOpen: make(map[string]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
if len(a.modalOpen) > 0 {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
return a.goBack()
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
a.buildPages()
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the TUI event loop.
|
||||||
|
func (a *App) Run() error {
|
||||||
|
return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) buildPages() {
|
||||||
|
a.pages.AddPage("home", a.newHomePage(), true, true)
|
||||||
|
a.pageStack = []string{"home"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) navigateTo(name string, page tview.Primitive) {
|
||||||
|
a.pages.RemovePage(name)
|
||||||
|
a.pages.AddPage(name, page, true, false)
|
||||||
|
a.pageStack = append(a.pageStack, name)
|
||||||
|
a.pages.SwitchToPage(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) goBack() *tcell.EventKey {
|
||||||
|
if len(a.pageStack) <= 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
popped := a.pageStack[len(a.pageStack)-1]
|
||||||
|
a.pageStack = a.pageStack[:len(a.pageStack)-1]
|
||||||
|
a.pages.RemovePage(popped)
|
||||||
|
prev := a.pageStack[len(a.pageStack)-1]
|
||||||
|
if fn, ok := a.pageRefreshFns[prev]; ok {
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
if prev == "home" && a.headerModelTV != nil {
|
||||||
|
a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ")
|
||||||
|
}
|
||||||
|
a.pages.SwitchToPage(prev)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) showModal(name string, primitive tview.Primitive) {
|
||||||
|
a.modalOpen[name] = true
|
||||||
|
a.pages.AddPage(name, primitive, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) hideModal(name string) {
|
||||||
|
delete(a.modalOpen, name)
|
||||||
|
a.pages.HidePage(name)
|
||||||
|
a.pages.RemovePage(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) save() {
|
||||||
|
if err := tuicfg.Save(a.configPath, a.cfg); err != nil {
|
||||||
|
a.showError("save failed: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) showError(msg string) {
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText(" [red::b]ERROR[-::-]\n\n" + msg).
|
||||||
|
AddButtons([]string{"OK"}).
|
||||||
|
SetDoneFunc(func(_ int, _ string) {
|
||||||
|
a.hideModal("error")
|
||||||
|
})
|
||||||
|
// Cyberpunk Modal Style
|
||||||
|
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||||
|
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||||
|
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red
|
||||||
|
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||||
|
a.showModal("error", modal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) confirmDelete(label string, onConfirm func()) {
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]").
|
||||||
|
AddButtons([]string{"Delete", "Cancel"}).
|
||||||
|
SetDoneFunc(func(_ int, buttonLabel string) {
|
||||||
|
a.hideModal("confirm-delete")
|
||||||
|
if buttonLabel == "Delete" {
|
||||||
|
onConfirm()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Cyberpunk Modal Style
|
||||||
|
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||||
|
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||||
|
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger
|
||||||
|
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||||
|
a.showModal("confirm-delete", modal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive {
|
||||||
|
return tview.NewFlex().
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false).
|
||||||
|
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false).
|
||||||
|
AddItem(form, height, 1, true).
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true).
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hintBar(text string) *tview.TextView {
|
||||||
|
tv := tview.NewTextView().
|
||||||
|
SetText(text).
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetTextAlign(tview.AlignCenter).
|
||||||
|
SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan
|
||||||
|
tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo
|
||||||
|
return tv
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive {
|
||||||
|
var modelTV *tview.TextView
|
||||||
|
if pageID == "home" {
|
||||||
|
if a.headerModelTV == nil {
|
||||||
|
a.headerModelTV = tview.NewTextView()
|
||||||
|
a.headerModelTV.SetTextAlign(tview.AlignRight).
|
||||||
|
SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
}
|
||||||
|
modelTV = a.headerModelTV
|
||||||
|
modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ")
|
||||||
|
} else {
|
||||||
|
modelTV = tview.NewTextView()
|
||||||
|
modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
}
|
||||||
|
|
||||||
|
headerLeft := tview.NewTextView().
|
||||||
|
SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///").
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
header := tview.NewFlex().
|
||||||
|
AddItem(headerLeft, 0, 1, false).
|
||||||
|
AddItem(modelTV, 0, 1, false)
|
||||||
|
|
||||||
|
sidebar := tview.NewTextView().
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetWrap(false)
|
||||||
|
sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||||
|
|
||||||
|
// Cyberpunk Sidebar Styling
|
||||||
|
activePrefix := "[#39ff14::b]>> " // Neon Lime arrow
|
||||||
|
activeSuffix := "[-]"
|
||||||
|
inactivePrefix := "[#808080] "
|
||||||
|
inactiveSuffix := "[-]"
|
||||||
|
|
||||||
|
sbText := "\n\n" // Top padding
|
||||||
|
|
||||||
|
menuItem := func(id, label string) string {
|
||||||
|
if pageID == id {
|
||||||
|
return activePrefix + label + activeSuffix + "\n\n"
|
||||||
|
}
|
||||||
|
return inactivePrefix + label + inactiveSuffix + "\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
sbText += menuItem("home", "HOME")
|
||||||
|
sbText += menuItem("schemes", "SCHEMES")
|
||||||
|
sbText += menuItem("users", "USERS")
|
||||||
|
sbText += menuItem("models", "MODELS")
|
||||||
|
sbText += menuItem("channels", "CHANNELS")
|
||||||
|
sbText += menuItem("gateway", "GATEWAY")
|
||||||
|
|
||||||
|
sidebar.SetText(sbText)
|
||||||
|
|
||||||
|
footer := hintBar(hint)
|
||||||
|
|
||||||
|
grid := tview.NewGrid().
|
||||||
|
SetRows(1, 0, 1).
|
||||||
|
SetColumns(20, 0). // Slightly wider sidebar
|
||||||
|
AddItem(header, 0, 0, 1, 2, 0, 0, false).
|
||||||
|
AddItem(sidebar, 1, 0, 1, 1, 0, 0, false).
|
||||||
|
AddItem(content, 1, 1, 1, 1, 0, 0, true).
|
||||||
|
AddItem(footer, 2, 0, 1, 2, 0, 0, false)
|
||||||
|
|
||||||
|
// Add a border around the content area if possible, or ensure content has its own border
|
||||||
|
// grid.SetBorders(false) // Grid borders usually look bad, handled by components
|
||||||
|
|
||||||
|
return grid
|
||||||
|
}
|
||||||
202
cmd/picoclaw-launcher-tui/ui/channels.go
Normal file
202
cmd/picoclaw-launcher-tui/ui/channels.go
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) newChannelsPage() tview.Primitive {
|
||||||
|
list := tview.NewList()
|
||||||
|
list.SetBorder(true).
|
||||||
|
SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
|
||||||
|
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
|
||||||
|
list.SetSelectedStyle(
|
||||||
|
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)),
|
||||||
|
)
|
||||||
|
list.SetHighlightFullLine(true)
|
||||||
|
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
rebuild := func() {
|
||||||
|
sel := list.GetCurrentItem()
|
||||||
|
list.Clear()
|
||||||
|
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
home = "."
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(home, ".picoclaw", "config.json")
|
||||||
|
|
||||||
|
var cfg map[string]any
|
||||||
|
if data, err := os.ReadFile(configPath); err == nil {
|
||||||
|
_ = json.Unmarshal(data, &cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if chRaw, ok := cfg["channels"].(map[string]any); ok {
|
||||||
|
for name, ch := range chRaw {
|
||||||
|
chMap, ok := ch.(map[string]any)
|
||||||
|
enabled := "disabled"
|
||||||
|
if ok {
|
||||||
|
if e, ok := chMap["enabled"].(bool); ok && e {
|
||||||
|
enabled = "enabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() {
|
||||||
|
a.showChannelEditForm(configPath, name, chMap)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sel >= 0 && sel < list.GetItemCount() {
|
||||||
|
list.SetCurrentItem(sel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rebuild()
|
||||||
|
|
||||||
|
a.pageRefreshFns["channels"] = rebuild
|
||||||
|
|
||||||
|
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
return a.goBack()
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) {
|
||||||
|
form := tview.NewForm()
|
||||||
|
form.SetBorder(true).
|
||||||
|
SetTitle(" [::b]EDIT CHANNEL ").
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||||
|
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||||
|
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||||
|
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||||
|
|
||||||
|
fields := make(map[string]*tview.InputField)
|
||||||
|
var nameField *tview.InputField
|
||||||
|
|
||||||
|
if channelName == "" {
|
||||||
|
nameField = tview.NewInputField().
|
||||||
|
SetLabel("Channel Name").
|
||||||
|
SetText("").
|
||||||
|
SetFieldWidth(28)
|
||||||
|
form.AddFormItem(nameField)
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range existing {
|
||||||
|
if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valStr := fmt.Sprintf("%v", v)
|
||||||
|
field := tview.NewInputField().
|
||||||
|
SetLabel(k).
|
||||||
|
SetText(valStr).
|
||||||
|
SetFieldWidth(28)
|
||||||
|
form.AddFormItem(field)
|
||||||
|
fields[k] = field
|
||||||
|
}
|
||||||
|
|
||||||
|
form.AddButton("SAVE", func() {
|
||||||
|
var cfg map[string]any
|
||||||
|
if data, err := os.ReadFile(configPath); err == nil {
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
cfg = make(map[string]any)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cfg = make(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := cfg["channels"]; !ok {
|
||||||
|
cfg["channels"] = make(map[string]any)
|
||||||
|
}
|
||||||
|
channels, ok := cfg["channels"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
channels = make(map[string]any)
|
||||||
|
cfg["channels"] = channels
|
||||||
|
}
|
||||||
|
|
||||||
|
finalName := channelName
|
||||||
|
if channelName == "" {
|
||||||
|
if nameField == nil || nameField.GetText() == "" {
|
||||||
|
a.showError("Channel name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
finalName = nameField.GetText()
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := make(map[string]any)
|
||||||
|
if existing != nil {
|
||||||
|
for k, v := range existing {
|
||||||
|
updated[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, field := range fields {
|
||||||
|
val := field.GetText()
|
||||||
|
if val == "true" {
|
||||||
|
updated[k] = true
|
||||||
|
} else if val == "false" {
|
||||||
|
updated[k] = false
|
||||||
|
} else if num, err := strconv.Atoi(val); err == nil {
|
||||||
|
updated[k] = num
|
||||||
|
} else {
|
||||||
|
updated[k] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if channelName != "" && finalName != channelName {
|
||||||
|
delete(channels, channelName)
|
||||||
|
}
|
||||||
|
channels[finalName] = updated
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
a.showError(fmt.Sprintf("Failed to save config: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
|
||||||
|
a.showError(fmt.Sprintf("Failed to create config directory: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(configPath, data, 0o600); err != nil {
|
||||||
|
a.showError(fmt.Sprintf("Failed to write config: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.hideModal("channel-edit")
|
||||||
|
a.goBack()
|
||||||
|
})
|
||||||
|
|
||||||
|
form.AddButton("CANCEL", func() {
|
||||||
|
a.hideModal("channel-edit")
|
||||||
|
})
|
||||||
|
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
a.hideModal("channel-edit")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
a.showModal("channel-edit", centeredForm(form, 4, 20))
|
||||||
|
}
|
||||||
229
cmd/picoclaw-launcher-tui/ui/gateway.go
Normal file
229
cmd/picoclaw-launcher-tui/ui/gateway.go
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
ppid "github.com/sipeed/picoclaw/pkg/pid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gatewayStatus struct {
|
||||||
|
running bool
|
||||||
|
pid int
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
|
||||||
|
func picoHome() string {
|
||||||
|
return config.GetHome()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGatewayStatus() gatewayStatus {
|
||||||
|
data := ppid.ReadPidFileWithCheck(picoHome())
|
||||||
|
if data == nil {
|
||||||
|
return gatewayStatus{running: false}
|
||||||
|
}
|
||||||
|
return gatewayStatus{
|
||||||
|
running: true,
|
||||||
|
pid: data.PID,
|
||||||
|
version: data.Version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startGateway() error {
|
||||||
|
status := getGatewayStatus()
|
||||||
|
if status.running {
|
||||||
|
return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1")
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 &")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
cmd := exec.Command(
|
||||||
|
"wmic",
|
||||||
|
"process",
|
||||||
|
"where",
|
||||||
|
"name='picoclaw.exe' and commandline like '%gateway%'",
|
||||||
|
"get",
|
||||||
|
"processid",
|
||||||
|
)
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get gateway PID: %w", err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
for _, line := range lines[1:] {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, err := strconv.Atoi(line)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status = getGatewayStatus()
|
||||||
|
if !status.running {
|
||||||
|
return fmt.Errorf("failed to start gateway")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopGateway() error {
|
||||||
|
status := getGatewayStatus()
|
||||||
|
if !status.running {
|
||||||
|
return fmt.Errorf("gateway is not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run()
|
||||||
|
} else {
|
||||||
|
err = exec.Command("kill", strconv.Itoa(status.pid)).Run()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for process to stop (ReadPidFileWithCheck cleans up stale pid file)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if !getGatewayStatus().running {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) newGatewayPage() tview.Primitive {
|
||||||
|
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||||
|
flex.SetBorder(true).
|
||||||
|
SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
flex.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
statusTV := tview.NewTextView().
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetTextAlign(tview.AlignCenter).
|
||||||
|
SetText("Checking status...")
|
||||||
|
statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
var updateStatus func()
|
||||||
|
|
||||||
|
// 使用List作为按钮,保证显示和交互正常
|
||||||
|
buttons := tview.NewList()
|
||||||
|
buttons.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
buttons.SetMainTextColor(tcell.ColorWhite)
|
||||||
|
buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||||
|
buttons.SetSelectedTextColor(tcell.ColorBlack)
|
||||||
|
|
||||||
|
buttons.AddItem(" [lime]START[white] ", "", 0, func() {
|
||||||
|
if !getGatewayStatus().running {
|
||||||
|
err := startGateway()
|
||||||
|
if err != nil {
|
||||||
|
a.showError(err.Error())
|
||||||
|
}
|
||||||
|
updateStatus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
buttons.AddItem(" [red]STOP[white] ", "", 0, func() {
|
||||||
|
if getGatewayStatus().running {
|
||||||
|
err := stopGateway()
|
||||||
|
if err != nil {
|
||||||
|
a.showError(err.Error())
|
||||||
|
}
|
||||||
|
updateStatus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
|
||||||
|
buttonFlex.
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false).
|
||||||
|
AddItem(buttons, 20, 1, true).
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false)
|
||||||
|
|
||||||
|
flex.
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false).
|
||||||
|
AddItem(statusTV, 3, 1, false).
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false).
|
||||||
|
AddItem(buttonFlex, 4, 1, true).
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false)
|
||||||
|
|
||||||
|
updateStatus = func() {
|
||||||
|
status := getGatewayStatus()
|
||||||
|
if status.running {
|
||||||
|
versionInfo := ""
|
||||||
|
if status.version != "" {
|
||||||
|
versionInfo = fmt.Sprintf("\nVersion: %s", status.version)
|
||||||
|
}
|
||||||
|
statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d%s", status.pid, versionInfo))
|
||||||
|
buttons.SetItemText(0, " [gray]START[white] ", "")
|
||||||
|
buttons.SetItemText(1, " [red]STOP[white] ", "")
|
||||||
|
} else {
|
||||||
|
statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A")
|
||||||
|
buttons.SetItemText(0, " [lime]START[white] ", "")
|
||||||
|
buttons.SetItemText(1, " [gray]STOP[white] ", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
a.tapp.QueueUpdateDraw(updateStatus)
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
originalInputCapture := flex.GetInputCapture()
|
||||||
|
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
close(done)
|
||||||
|
return a.goBack()
|
||||||
|
}
|
||||||
|
if originalInputCapture != nil {
|
||||||
|
return originalInputCapture(event)
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
a.pageRefreshFns["gateway"] = updateStatus
|
||||||
|
|
||||||
|
return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ")
|
||||||
|
}
|
||||||
70
cmd/picoclaw-launcher-tui/ui/home.go
Normal file
70
cmd/picoclaw-launcher-tui/ui/home.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) newHomePage() tview.Primitive {
|
||||||
|
list := tview.NewList()
|
||||||
|
list.SetBorder(true).
|
||||||
|
SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
|
||||||
|
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
|
||||||
|
list.SetSelectedStyle(
|
||||||
|
tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)),
|
||||||
|
)
|
||||||
|
list.SetHighlightFullLine(true)
|
||||||
|
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
rebuildList := func() {
|
||||||
|
sel := list.GetCurrentItem()
|
||||||
|
list.Clear()
|
||||||
|
list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() {
|
||||||
|
a.navigateTo("schemes", a.newSchemesPage())
|
||||||
|
})
|
||||||
|
list.AddItem(
|
||||||
|
"CHANNELS: Configure communication channels",
|
||||||
|
"Manage Telegram/Discord/WeChat channels",
|
||||||
|
'n',
|
||||||
|
func() {
|
||||||
|
a.navigateTo("channels", a.newChannelsPage())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() {
|
||||||
|
a.navigateTo("gateway", a.newGatewayPage())
|
||||||
|
})
|
||||||
|
list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() {
|
||||||
|
a.tapp.Suspend(func() {
|
||||||
|
cmd := exec.Command("picoclaw", "agent")
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
_ = cmd.Run()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() })
|
||||||
|
if sel >= 0 && sel < list.GetItemCount() {
|
||||||
|
list.SetCurrentItem(sel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rebuildList()
|
||||||
|
|
||||||
|
a.pageRefreshFns["home"] = rebuildList
|
||||||
|
|
||||||
|
return a.buildShell(
|
||||||
|
"home",
|
||||||
|
list,
|
||||||
|
" [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ",
|
||||||
|
)
|
||||||
|
}
|
||||||
200
cmd/picoclaw-launcher-tui/ui/models.go
Normal file
200
cmd/picoclaw-launcher-tui/ui/models.go
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type modelsAPIResponse struct {
|
||||||
|
Data []modelEntry `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive {
|
||||||
|
table := tview.NewTable().
|
||||||
|
SetBorders(false).
|
||||||
|
SetSelectable(true, false).
|
||||||
|
SetFixed(0, 0)
|
||||||
|
table.SetBorder(true).
|
||||||
|
SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
table.SetSelectedStyle(
|
||||||
|
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||||
|
)
|
||||||
|
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
var modelIDs []string
|
||||||
|
|
||||||
|
status := tview.NewTextView().
|
||||||
|
SetTextAlign(tview.AlignCenter).
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetText("[#ffff00]FETCHING MODELS...[-]")
|
||||||
|
status.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
flex := tview.NewFlex().
|
||||||
|
SetDirection(tview.FlexRow).
|
||||||
|
AddItem(status, 1, 0, false).
|
||||||
|
AddItem(table, 0, 1, false)
|
||||||
|
|
||||||
|
apiKey := a.resolveKey(schemeName, userName)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
var entries []modelEntry
|
||||||
|
var err error
|
||||||
|
if apiKey == "" {
|
||||||
|
err = fmt.Errorf("key is required")
|
||||||
|
} else {
|
||||||
|
entries, err = fetchModels(baseURL, apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.modelCacheMu.Lock()
|
||||||
|
if a.modelCache == nil {
|
||||||
|
a.modelCache = make(map[string][]modelEntry)
|
||||||
|
}
|
||||||
|
if err == nil && len(entries) > 0 {
|
||||||
|
a.modelCache[cacheKey(schemeName, userName)] = entries
|
||||||
|
} else {
|
||||||
|
a.modelCache[cacheKey(schemeName, userName)] = nil
|
||||||
|
}
|
||||||
|
a.modelCacheMu.Unlock()
|
||||||
|
|
||||||
|
a.tapp.QueueUpdateDraw(func() {
|
||||||
|
if err != nil {
|
||||||
|
status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error()))
|
||||||
|
table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)"))
|
||||||
|
a.tapp.SetFocus(table)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
status.SetText("[#ff2a2a]NO MODELS RETURNED[-]")
|
||||||
|
table.SetCell(0, 0, tview.NewTableCell(" (no models available)"))
|
||||||
|
a.tapp.SetFocus(table)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries)))
|
||||||
|
for i, m := range entries {
|
||||||
|
modelIDs = append(modelIDs, m.ID)
|
||||||
|
table.SetCell(i, 0,
|
||||||
|
tview.NewTableCell(fmt.Sprintf("%3d", i+1)).
|
||||||
|
SetAlign(tview.AlignRight).
|
||||||
|
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||||
|
SetSelectable(false),
|
||||||
|
)
|
||||||
|
table.SetCell(i, 1,
|
||||||
|
tview.NewTableCell(" "+m.ID).
|
||||||
|
SetAlign(tview.AlignLeft).
|
||||||
|
SetExpansion(1).
|
||||||
|
SetTextColor(tcell.NewHexColor(0xe0e0e0)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
a.tapp.SetFocus(table)
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
table.SetSelectedFunc(func(row, _ int) {
|
||||||
|
if row < 0 || row >= len(modelIDs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.cfg.Provider.Current = tuicfg.ProviderCurrent{
|
||||||
|
Scheme: schemeName,
|
||||||
|
User: userName,
|
||||||
|
Model: modelIDs[row],
|
||||||
|
}
|
||||||
|
a.save()
|
||||||
|
|
||||||
|
// Trigger model selected callback if set
|
||||||
|
if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" {
|
||||||
|
scheme := a.cfg.Provider.SchemeByName(schemeName)
|
||||||
|
if scheme == nil {
|
||||||
|
a.goBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var user tuicfg.User
|
||||||
|
for _, u := range a.cfg.Provider.Users {
|
||||||
|
if u.Scheme == schemeName && u.Name == userName {
|
||||||
|
user = u
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.OnModelSelected(*scheme, user, modelIDs[row])
|
||||||
|
}
|
||||||
|
|
||||||
|
a.goBack()
|
||||||
|
})
|
||||||
|
|
||||||
|
return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) resolveKey(schemeName, userName string) string {
|
||||||
|
for _, u := range a.cfg.Provider.Users {
|
||||||
|
if u.Scheme == schemeName && u.Name == userName {
|
||||||
|
return u.Key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
|
||||||
|
url := strings.TrimRight(baseURL, "/") + "/models"
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
if apiKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result modelsAPIResponse
|
||||||
|
if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 {
|
||||||
|
return result.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var arr []modelEntry
|
||||||
|
if err := json.Unmarshal(body, &arr); err == nil {
|
||||||
|
return arr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"decode response: unrecognized shape: %s",
|
||||||
|
strings.TrimSpace(string(body[:min(len(body), 256)])),
|
||||||
|
)
|
||||||
|
}
|
||||||
252
cmd/picoclaw-launcher-tui/ui/schemes.go
Normal file
252
cmd/picoclaw-launcher-tui/ui/schemes.go
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) newSchemesPage() tview.Primitive {
|
||||||
|
table := tview.NewTable().
|
||||||
|
SetBorders(false).
|
||||||
|
SetSelectable(true, false)
|
||||||
|
table.SetBorder(true).
|
||||||
|
SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
table.SetSelectedStyle(
|
||||||
|
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||||
|
)
|
||||||
|
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
rowToIdx := func(row int) int { return row / 2 }
|
||||||
|
|
||||||
|
selectedSchemeName := func() string {
|
||||||
|
row, _ := table.GetSelection()
|
||||||
|
idx := rowToIdx(row)
|
||||||
|
schemes := a.cfg.Provider.Schemes
|
||||||
|
if idx >= 0 && idx < len(schemes) {
|
||||||
|
return schemes[idx].Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuild := func() {
|
||||||
|
selName := selectedSchemeName()
|
||||||
|
table.Clear()
|
||||||
|
schemes := a.cfg.Provider.Schemes
|
||||||
|
for i, s := range schemes {
|
||||||
|
nameRow := i * 2
|
||||||
|
detailRow := nameRow + 1
|
||||||
|
|
||||||
|
table.SetCell(nameRow, 0,
|
||||||
|
tview.NewTableCell(" "+s.Name).
|
||||||
|
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
|
||||||
|
SetExpansion(1).
|
||||||
|
SetSelectable(true),
|
||||||
|
)
|
||||||
|
|
||||||
|
users := a.cfg.Provider.UsersForScheme(s.Name)
|
||||||
|
n := len(users)
|
||||||
|
m := 0
|
||||||
|
for _, u := range users {
|
||||||
|
if models := a.cachedModels(s.Name, u.Name); len(models) > 0 {
|
||||||
|
m++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table.SetCell(detailRow, 0,
|
||||||
|
tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)).
|
||||||
|
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||||
|
SetExpansion(1).
|
||||||
|
SetSelectable(false),
|
||||||
|
)
|
||||||
|
table.SetCell(detailRow, 1,
|
||||||
|
tview.NewTableCell("[#00f0ff]"+s.Type+" ").
|
||||||
|
SetAlign(tview.AlignRight).
|
||||||
|
SetSelectable(false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if selName != "" {
|
||||||
|
for i, s := range schemes {
|
||||||
|
if s.Name == selName {
|
||||||
|
table.Select(i*2, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if table.GetRowCount() > 0 {
|
||||||
|
table.Select(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rebuild()
|
||||||
|
|
||||||
|
a.refreshModelCache(rebuild)
|
||||||
|
a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) }
|
||||||
|
|
||||||
|
table.SetSelectedFunc(func(row, _ int) {
|
||||||
|
idx := rowToIdx(row)
|
||||||
|
schemes := a.cfg.Provider.Schemes
|
||||||
|
if idx < 0 || idx >= len(schemes) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := schemes[idx].Name
|
||||||
|
a.navigateTo("users", a.newUsersPage(name))
|
||||||
|
})
|
||||||
|
|
||||||
|
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
row, _ := table.GetSelection()
|
||||||
|
idx := rowToIdx(row)
|
||||||
|
schemes := a.cfg.Provider.Schemes
|
||||||
|
switch event.Rune() {
|
||||||
|
case 'a':
|
||||||
|
a.showSchemeForm(nil, func(s tuicfg.Scheme) {
|
||||||
|
a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
|
||||||
|
a.save()
|
||||||
|
a.refreshModelCache(rebuild)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
case 'e':
|
||||||
|
if idx < 0 || idx >= len(schemes) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
origName := schemes[idx].Name
|
||||||
|
orig := schemes[idx]
|
||||||
|
a.showSchemeForm(&orig, func(s tuicfg.Scheme) {
|
||||||
|
current := a.cfg.Provider.Schemes
|
||||||
|
for i, sc := range current {
|
||||||
|
if sc.Name == origName {
|
||||||
|
a.cfg.Provider.Schemes[i] = s
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.save()
|
||||||
|
a.refreshModelCache(func() {
|
||||||
|
rebuild()
|
||||||
|
for i, sc := range a.cfg.Provider.Schemes {
|
||||||
|
if sc.Name == s.Name {
|
||||||
|
table.Select(i*2, 0)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
case 'd':
|
||||||
|
if idx < 0 || idx >= len(schemes) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := schemes[idx].Name
|
||||||
|
a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
|
||||||
|
current := a.cfg.Provider.Schemes
|
||||||
|
newSchemes := make([]tuicfg.Scheme, 0, len(current))
|
||||||
|
for _, sc := range current {
|
||||||
|
if sc.Name != name {
|
||||||
|
newSchemes = append(newSchemes, sc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.cfg.Provider.Schemes = newSchemes
|
||||||
|
|
||||||
|
existing := a.cfg.Provider.Users
|
||||||
|
filtered := make([]tuicfg.User, 0, len(existing))
|
||||||
|
for _, u := range existing {
|
||||||
|
if u.Scheme != name {
|
||||||
|
filtered = append(filtered, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.cfg.Provider.Users = filtered
|
||||||
|
|
||||||
|
a.save()
|
||||||
|
a.refreshModelCache(rebuild)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
return a.buildShell(
|
||||||
|
"schemes",
|
||||||
|
table,
|
||||||
|
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
|
||||||
|
name := ""
|
||||||
|
baseURL := ""
|
||||||
|
schemeType := "openai-compatible"
|
||||||
|
title := " ADD SCHEME "
|
||||||
|
|
||||||
|
if existing != nil {
|
||||||
|
name = existing.Name
|
||||||
|
baseURL = existing.BaseURL
|
||||||
|
schemeType = existing.Type
|
||||||
|
title = " EDIT SCHEME "
|
||||||
|
}
|
||||||
|
|
||||||
|
typeOptions := []string{"openai-compatible", "anthropic"}
|
||||||
|
typeIdx := 0
|
||||||
|
for i, t := range typeOptions {
|
||||||
|
if t == schemeType {
|
||||||
|
typeIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form := tview.NewForm()
|
||||||
|
|
||||||
|
form.
|
||||||
|
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
|
||||||
|
AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }).
|
||||||
|
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
|
||||||
|
AddButton("SAVE", func() {
|
||||||
|
if name == "" {
|
||||||
|
a.showError("Name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if baseURL == "" {
|
||||||
|
a.showError("Base URL is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
for _, s := range a.cfg.Provider.Schemes {
|
||||||
|
if s.Name == name {
|
||||||
|
a.showError(fmt.Sprintf("Scheme name %q already exists", name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.hideModal("scheme-form")
|
||||||
|
onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType})
|
||||||
|
}).
|
||||||
|
AddButton("CANCEL", func() {
|
||||||
|
a.hideModal("scheme-form")
|
||||||
|
})
|
||||||
|
|
||||||
|
form.SetBorder(true).
|
||||||
|
SetTitle(" [::b]" + title + " ").
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||||
|
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||||
|
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||||
|
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
a.hideModal("scheme-form")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
a.showModal("scheme-form", centeredForm(form, 4, 12))
|
||||||
|
}
|
||||||
261
cmd/picoclaw-launcher-tui/ui/users.go
Normal file
261
cmd/picoclaw-launcher-tui/ui/users.go
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) newUsersPage(schemeName string) tview.Primitive {
|
||||||
|
table := tview.NewTable().
|
||||||
|
SetBorders(false).
|
||||||
|
SetSelectable(true, false)
|
||||||
|
table.SetBorder(true).
|
||||||
|
SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
table.SetSelectedStyle(
|
||||||
|
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||||
|
)
|
||||||
|
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
visibleUsers := func() []tuicfg.User {
|
||||||
|
var out []tuicfg.User
|
||||||
|
for _, u := range a.cfg.Provider.Users {
|
||||||
|
if u.Scheme == schemeName {
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
findUserGlobalIdx := func(userName string) int {
|
||||||
|
for i, u := range a.cfg.Provider.Users {
|
||||||
|
if u.Scheme == schemeName && u.Name == userName {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
rowToVisIdx := func(row int) int { return row / 2 }
|
||||||
|
|
||||||
|
selectedUserName := func() string {
|
||||||
|
row, _ := table.GetSelection()
|
||||||
|
users := visibleUsers()
|
||||||
|
visIdx := rowToVisIdx(row)
|
||||||
|
if visIdx >= 0 && visIdx < len(users) {
|
||||||
|
return users[visIdx].Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuild := func() {
|
||||||
|
selName := selectedUserName()
|
||||||
|
table.Clear()
|
||||||
|
users := visibleUsers()
|
||||||
|
for i, u := range users {
|
||||||
|
nameRow := i * 2
|
||||||
|
detailRow := nameRow + 1
|
||||||
|
|
||||||
|
table.SetCell(nameRow, 0,
|
||||||
|
tview.NewTableCell(" "+u.Name).
|
||||||
|
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
|
||||||
|
SetExpansion(1).
|
||||||
|
SetSelectable(true),
|
||||||
|
)
|
||||||
|
table.SetCell(nameRow, 1,
|
||||||
|
tview.NewTableCell("").
|
||||||
|
SetSelectable(false),
|
||||||
|
)
|
||||||
|
|
||||||
|
models := a.cachedModels(schemeName, u.Name)
|
||||||
|
var detailText string
|
||||||
|
if len(models) > 0 {
|
||||||
|
detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models))
|
||||||
|
} else {
|
||||||
|
detailText = " [#ff2a2a]Inactive / No Access[-]"
|
||||||
|
}
|
||||||
|
table.SetCell(detailRow, 0,
|
||||||
|
tview.NewTableCell(detailText).
|
||||||
|
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||||
|
SetExpansion(1).
|
||||||
|
SetSelectable(false),
|
||||||
|
)
|
||||||
|
table.SetCell(detailRow, 1,
|
||||||
|
tview.NewTableCell("[#00f0ff]"+u.Type+" ").
|
||||||
|
SetAlign(tview.AlignRight).
|
||||||
|
SetSelectable(false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if selName != "" {
|
||||||
|
for i, u := range users {
|
||||||
|
if u.Name == selName {
|
||||||
|
table.Select(i*2, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if table.GetRowCount() > 0 {
|
||||||
|
table.Select(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rebuild()
|
||||||
|
|
||||||
|
a.refreshModelCache(rebuild)
|
||||||
|
a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) }
|
||||||
|
|
||||||
|
table.SetSelectedFunc(func(row, _ int) {
|
||||||
|
visIdx := rowToVisIdx(row)
|
||||||
|
users := visibleUsers()
|
||||||
|
if visIdx < 0 || visIdx >= len(users) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uName := users[visIdx].Name
|
||||||
|
scheme := a.cfg.Provider.SchemeByName(schemeName)
|
||||||
|
if scheme == nil {
|
||||||
|
a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
|
||||||
|
})
|
||||||
|
|
||||||
|
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
row, _ := table.GetSelection()
|
||||||
|
visIdx := rowToVisIdx(row)
|
||||||
|
users := visibleUsers()
|
||||||
|
switch event.Rune() {
|
||||||
|
case 'a':
|
||||||
|
a.showUserForm(schemeName, nil, func(u tuicfg.User) {
|
||||||
|
a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
|
||||||
|
a.save()
|
||||||
|
a.refreshModelCache(rebuild)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
case 'e':
|
||||||
|
if visIdx < 0 || visIdx >= len(users) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
origName := users[visIdx].Name
|
||||||
|
orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)]
|
||||||
|
a.showUserForm(schemeName, &orig, func(u tuicfg.User) {
|
||||||
|
cfgIdx := findUserGlobalIdx(origName)
|
||||||
|
if cfgIdx < 0 {
|
||||||
|
a.showError(fmt.Sprintf("User %q no longer exists", origName))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.cfg.Provider.Users[cfgIdx] = u
|
||||||
|
a.save()
|
||||||
|
a.refreshModelCache(func() {
|
||||||
|
rebuild()
|
||||||
|
for i, usr := range visibleUsers() {
|
||||||
|
if usr.Name == u.Name {
|
||||||
|
table.Select(i*2, 0)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
case 'd':
|
||||||
|
if visIdx < 0 || visIdx >= len(users) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
uName := users[visIdx].Name
|
||||||
|
a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
|
||||||
|
cfgIdx := findUserGlobalIdx(uName)
|
||||||
|
if cfgIdx < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
all := a.cfg.Provider.Users
|
||||||
|
a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...)
|
||||||
|
a.save()
|
||||||
|
a.refreshModelCache(rebuild)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
return a.buildShell(
|
||||||
|
"users",
|
||||||
|
table,
|
||||||
|
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
|
||||||
|
name := ""
|
||||||
|
userType := "key"
|
||||||
|
key := ""
|
||||||
|
title := " ADD USER "
|
||||||
|
|
||||||
|
if existing != nil {
|
||||||
|
name = existing.Name
|
||||||
|
userType = existing.Type
|
||||||
|
key = existing.Key
|
||||||
|
title = " EDIT USER "
|
||||||
|
}
|
||||||
|
|
||||||
|
typeOptions := []string{"key", "OAuth"}
|
||||||
|
typeIdx := 0
|
||||||
|
for i, t := range typeOptions {
|
||||||
|
if t == userType {
|
||||||
|
typeIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form := tview.NewForm()
|
||||||
|
form.
|
||||||
|
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
|
||||||
|
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
|
||||||
|
AddPasswordField("Key", key, 28, '*', func(text string) { key = text }).
|
||||||
|
AddButton("SAVE", func() {
|
||||||
|
if name == "" {
|
||||||
|
a.showError("Name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
for _, u := range a.cfg.Provider.Users {
|
||||||
|
if u.Scheme == schemeName && u.Name == name {
|
||||||
|
a.showError(fmt.Sprintf("User name %q already exists for this scheme", name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.hideModal("user-form")
|
||||||
|
onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key})
|
||||||
|
}).
|
||||||
|
AddButton("CANCEL", func() {
|
||||||
|
a.hideModal("user-form")
|
||||||
|
})
|
||||||
|
|
||||||
|
form.SetBorder(true).
|
||||||
|
SetTitle(" [::b]" + title + " ").
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||||
|
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||||
|
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||||
|
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
a.hideModal("user-form")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
a.showModal("user-form", centeredForm(form, 4, 13))
|
||||||
|
}
|
||||||
|
|
@ -17,24 +17,24 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity, antigravity"
|
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
||||||
defaultAnthropicModel = "claude-sonnet-4.6"
|
defaultAnthropicModel = "claude-sonnet-4.6"
|
||||||
)
|
)
|
||||||
|
|
||||||
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool, noBrowser bool) error {
|
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "openai":
|
case "openai":
|
||||||
return authLoginOpenAI(useDeviceCode, noBrowser)
|
return authLoginOpenAI(useDeviceCode)
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
return authLoginAnthropic(useOauth)
|
return authLoginAnthropic(useOauth)
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
return authLoginGoogleAntigravity(noBrowser)
|
return authLoginGoogleAntigravity()
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
|
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error {
|
func authLoginOpenAI(useDeviceCode bool) error {
|
||||||
cfg := auth.OpenAIOAuthConfig()
|
cfg := auth.OpenAIOAuthConfig()
|
||||||
|
|
||||||
var cred *auth.AuthCredential
|
var cred *auth.AuthCredential
|
||||||
|
|
@ -43,7 +43,7 @@ func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error {
|
||||||
if useDeviceCode {
|
if useDeviceCode {
|
||||||
cred, err = auth.LoginDeviceCode(cfg)
|
cred, err = auth.LoginDeviceCode(cfg)
|
||||||
} else {
|
} else {
|
||||||
cred, err = auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser})
|
cred, err = auth.LoginBrowser(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -59,7 +59,7 @@ func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error {
|
||||||
// Update or add openai in ModelList
|
// Update or add openai in ModelList
|
||||||
foundOpenAI := false
|
foundOpenAI := false
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
if isOpenAIModel(appCfg.ModelList[i]) {
|
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
||||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||||
foundOpenAI = true
|
foundOpenAI = true
|
||||||
break
|
break
|
||||||
|
|
@ -92,10 +92,10 @@ func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func authLoginGoogleAntigravity(noBrowser bool) error {
|
func authLoginGoogleAntigravity() error {
|
||||||
cfg := auth.GoogleAntigravityOAuthConfig()
|
cfg := auth.GoogleAntigravityOAuthConfig()
|
||||||
|
|
||||||
cred, err := auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser})
|
cred, err := auth.LoginBrowser(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("login failed: %w", err)
|
return fmt.Errorf("login failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -130,7 +130,7 @@ func authLoginGoogleAntigravity(noBrowser bool) error {
|
||||||
// Update or add antigravity in ModelList
|
// Update or add antigravity in ModelList
|
||||||
foundAntigravity := false
|
foundAntigravity := false
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
if isAntigravityModel(appCfg.ModelList[i]) {
|
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
||||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||||
foundAntigravity = true
|
foundAntigravity = true
|
||||||
break
|
break
|
||||||
|
|
@ -206,7 +206,7 @@ func authLoginAnthropicSetupToken() error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
found := false
|
found := false
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
if isAnthropicModel(appCfg.ModelList[i]) {
|
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
|
|
@ -282,7 +282,7 @@ func authLoginPasteToken(provider string) error {
|
||||||
// Update ModelList
|
// Update ModelList
|
||||||
found := false
|
found := false
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
if isAnthropicModel(appCfg.ModelList[i]) {
|
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||||
appCfg.ModelList[i].AuthMethod = "token"
|
appCfg.ModelList[i].AuthMethod = "token"
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
|
|
@ -300,7 +300,7 @@ func authLoginPasteToken(provider string) error {
|
||||||
// Update ModelList
|
// Update ModelList
|
||||||
found := false
|
found := false
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
if isOpenAIModel(appCfg.ModelList[i]) {
|
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
||||||
appCfg.ModelList[i].AuthMethod = "token"
|
appCfg.ModelList[i].AuthMethod = "token"
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
|
|
@ -342,15 +342,15 @@ func authLogoutCmd(provider string) error {
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "openai":
|
case "openai":
|
||||||
if isOpenAIModel(appCfg.ModelList[i]) {
|
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
||||||
appCfg.ModelList[i].AuthMethod = ""
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
}
|
}
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
if isAnthropicModel(appCfg.ModelList[i]) {
|
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||||
appCfg.ModelList[i].AuthMethod = ""
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
}
|
}
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
if isAntigravityModel(appCfg.ModelList[i]) {
|
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
||||||
appCfg.ModelList[i].AuthMethod = ""
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -484,20 +484,22 @@ func authModelsCmd() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAntigravityModel checks if a model config belongs to an Antigravity provider.
|
// isAntigravityModel checks if a model string belongs to antigravity provider
|
||||||
func isAntigravityModel(modelCfg *config.ModelConfig) bool {
|
func isAntigravityModel(model string) bool {
|
||||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
return model == "antigravity" ||
|
||||||
return protocol == "antigravity" || protocol == "google-antigravity"
|
model == "google-antigravity" ||
|
||||||
|
strings.HasPrefix(model, "antigravity/") ||
|
||||||
|
strings.HasPrefix(model, "google-antigravity/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// isOpenAIModel checks if a model config belongs to the OpenAI provider.
|
// isOpenAIModel checks if a model string belongs to openai provider
|
||||||
func isOpenAIModel(modelCfg *config.ModelConfig) bool {
|
func isOpenAIModel(model string) bool {
|
||||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
return model == "openai" ||
|
||||||
return protocol == "openai"
|
strings.HasPrefix(model, "openai/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAnthropicModel checks if a model config belongs to the Anthropic provider.
|
// isAnthropicModel checks if a model string belongs to anthropic provider
|
||||||
func isAnthropicModel(modelCfg *config.ModelConfig) bool {
|
func isAnthropicModel(model string) bool {
|
||||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
return model == "anthropic" ||
|
||||||
return protocol == "anthropic"
|
strings.HasPrefix(model, "anthropic/")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ func newLoginCommand() *cobra.Command {
|
||||||
provider string
|
provider string
|
||||||
useDeviceCode bool
|
useDeviceCode bool
|
||||||
useOauth bool
|
useOauth bool
|
||||||
noBrowser bool
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|
@ -15,15 +14,12 @@ 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, useOauth, noBrowser)
|
return authLoginCmd(provider, useDeviceCode, useOauth)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
|
||||||
&provider, "provider", "p", "", "Provider to login with (openai, anthropic, google-antigravity, antigravity)",
|
|
||||||
)
|
|
||||||
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(&noBrowser, "no-browser", false, "Do not auto-open a browser during OAuth login")
|
|
||||||
cmd.Flags().BoolVar(
|
cmd.Flags().BoolVar(
|
||||||
&useOauth, "setup-token", false,
|
&useOauth, "setup-token", false,
|
||||||
"Use setup-token flow for Anthropic (from `claude setup-token`)",
|
"Use setup-token flow for Anthropic (from `claude setup-token`)",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ func TestNewLoginSubCommand(t *testing.T) {
|
||||||
assert.True(t, cmd.HasFlags())
|
assert.True(t, cmd.HasFlags())
|
||||||
|
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("device-code"))
|
assert.NotNil(t, cmd.Flags().Lookup("device-code"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("no-browser"))
|
|
||||||
|
|
||||||
providerFlag := cmd.Flags().Lookup("provider")
|
providerFlag := cmd.Flags().Lookup("provider")
|
||||||
require.NotNil(t, providerFlag)
|
require.NotNil(t, providerFlag)
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,12 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
pkgauth "github.com/sipeed/picoclaw/pkg/auth"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func captureAuthStdout(t *testing.T, fn func()) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
oldStdout := os.Stdout
|
|
||||||
r, w, err := os.Pipe()
|
|
||||||
require.NoError(t, err)
|
|
||||||
os.Stdout = w
|
|
||||||
t.Cleanup(func() {
|
|
||||||
os.Stdout = oldStdout
|
|
||||||
})
|
|
||||||
|
|
||||||
fn()
|
|
||||||
|
|
||||||
require.NoError(t, w.Close())
|
|
||||||
os.Stdout = oldStdout
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_, err = io.Copy(&buf, r)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, r.Close())
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setAuthStatusTestHome(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
t.Setenv(config.EnvHome, filepath.Join(tmpDir, ".picoclaw"))
|
|
||||||
return tmpDir
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewStatusSubcommand(t *testing.T) {
|
func TestNewStatusSubcommand(t *testing.T) {
|
||||||
cmd := newStatusCommand()
|
cmd := newStatusCommand()
|
||||||
|
|
||||||
|
|
@ -57,47 +16,3 @@ func TestNewStatusSubcommand(t *testing.T) {
|
||||||
|
|
||||||
assert.False(t, cmd.HasFlags())
|
assert.False(t, cmd.HasFlags())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthStatusCmdShowsCanonicalGoogleAntigravityAfterLegacyRefresh(t *testing.T) {
|
|
||||||
tmpDir := setAuthStatusTestHome(t)
|
|
||||||
|
|
||||||
legacyExpiry := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC)
|
|
||||||
legacyStore := map[string]any{
|
|
||||||
"credentials": map[string]any{
|
|
||||||
"antigravity": map[string]any{
|
|
||||||
"access_token": "legacy-token",
|
|
||||||
"expires_at": legacyExpiry.Format(time.RFC3339),
|
|
||||||
"provider": "antigravity",
|
|
||||||
"auth_method": "oauth",
|
|
||||||
"project_id": "legacy-project",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
data, err := json.Marshal(legacyStore)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
authPath := filepath.Join(tmpDir, ".picoclaw", "auth.json")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(authPath), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(authPath, data, 0o600))
|
|
||||||
|
|
||||||
refreshedExpiry := time.Date(2026, 4, 16, 12, 30, 0, 0, time.UTC)
|
|
||||||
err = pkgauth.SetCredential("google-antigravity", &pkgauth.AuthCredential{
|
|
||||||
AccessToken: "fresh-token",
|
|
||||||
ExpiresAt: refreshedExpiry,
|
|
||||||
Provider: "google-antigravity",
|
|
||||||
AuthMethod: "oauth",
|
|
||||||
ProjectID: "fresh-project",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
output := captureAuthStdout(t, func() {
|
|
||||||
require.NoError(t, authStatusCmd())
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Contains(t, output, "\nAuthenticated Providers:")
|
|
||||||
assert.Contains(t, output, "\n google-antigravity:\n")
|
|
||||||
assert.NotContains(t, output, "\n antigravity:\n")
|
|
||||||
assert.Contains(t, output, " Project: fresh-project")
|
|
||||||
assert.Contains(t, output, " Expires: 2026-04-16 12:30")
|
|
||||||
assert.Equal(t, 1, strings.Count(output, ":\n Method: oauth"))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -156,31 +155,11 @@ func defaultWeComQRFlowOptions(timeout time.Duration) wecomQRFlowOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyWeComAuthResult(cfg *config.Config, botInfo wecomQRBotInfo) {
|
func applyWeComAuthResult(cfg *config.Config, botInfo wecomQRBotInfo) {
|
||||||
bc := cfg.Channels.GetByType(config.ChannelWeCom)
|
cfg.Channels.WeCom.Enabled = true
|
||||||
if bc == nil {
|
cfg.Channels.WeCom.BotID = botInfo.BotID
|
||||||
bc = &config.Channel{Type: config.ChannelWeCom}
|
cfg.Channels.WeCom.SetSecret(botInfo.Secret)
|
||||||
cfg.Channels["wecom"] = bc
|
if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" {
|
||||||
}
|
cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL
|
||||||
bc.Enabled = true
|
|
||||||
|
|
||||||
decoded, err := bc.GetDecoded()
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("wecom", "failed to decode WeCom settings", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
wecomCfg, ok := decoded.(*config.WeComSettings)
|
|
||||||
if !ok {
|
|
||||||
logger.ErrorCF("wecom", "unexpected WeCom settings type", map[string]any{
|
|
||||||
"got": fmt.Sprintf("%T", decoded),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
wecomCfg.BotID = botInfo.BotID
|
|
||||||
wecomCfg.Secret = *config.NewSecureString(botInfo.Secret)
|
|
||||||
if strings.TrimSpace(wecomCfg.WebSocketURL) == "" {
|
|
||||||
wecomCfg.WebSocketURL = wecomDefaultWebSocketURL
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package auth
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
@ -20,19 +19,6 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newIPv4TestServer(t *testing.T, handler http.Handler) *httptest.Server {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
server := httptest.NewUnstartedServer(handler)
|
|
||||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
server.Listener = listener
|
|
||||||
server.Start()
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
return server
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewWeComCommand(t *testing.T) {
|
func TestNewWeComCommand(t *testing.T) {
|
||||||
cmd := newWeComCommand()
|
cmd := newWeComCommand()
|
||||||
|
|
||||||
|
|
@ -67,7 +53,7 @@ func TestBuildWeComQRCodePageURL(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchWeComQRCode(t *testing.T) {
|
func TestFetchWeComQRCode(t *testing.T) {
|
||||||
server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, "/generate", r.URL.Path)
|
assert.Equal(t, "/generate", r.URL.Path)
|
||||||
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("source"))
|
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("source"))
|
||||||
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("sourceID"))
|
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("sourceID"))
|
||||||
|
|
@ -75,6 +61,7 @@ func TestFetchWeComQRCode(t *testing.T) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`))
|
_, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`))
|
||||||
}))
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
|
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
|
||||||
HTTPClient: server.Client(),
|
HTTPClient: server.Client(),
|
||||||
|
|
@ -91,7 +78,7 @@ func TestFetchWeComQRCode(t *testing.T) {
|
||||||
func TestPollWeComQRCodeResult(t *testing.T) {
|
func TestPollWeComQRCodeResult(t *testing.T) {
|
||||||
var calls atomic.Int32
|
var calls atomic.Int32
|
||||||
|
|
||||||
server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
call := calls.Add(1)
|
call := calls.Add(1)
|
||||||
assert.Equal(t, "/query", r.URL.Path)
|
assert.Equal(t, "/query", r.URL.Path)
|
||||||
assert.Equal(t, "scode-1", r.URL.Query().Get("scode"))
|
assert.Equal(t, "scode-1", r.URL.Query().Get("scode"))
|
||||||
|
|
@ -105,6 +92,7 @@ func TestPollWeComQRCodeResult(t *testing.T) {
|
||||||
_, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`))
|
_, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`))
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
|
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
|
||||||
|
|
@ -124,23 +112,17 @@ func TestPollWeComQRCodeResult(t *testing.T) {
|
||||||
|
|
||||||
func TestApplyWeComAuthResult(t *testing.T) {
|
func TestApplyWeComAuthResult(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
require.NoError(t, config.InitChannelList(cfg.Channels))
|
cfg.Channels.WeCom.WebSocketURL = ""
|
||||||
wecom := cfg.Channels["wecom"]
|
|
||||||
t.Logf("wecom: %+v", wecom)
|
|
||||||
decoded, err := wecom.GetDecoded()
|
|
||||||
require.NoError(t, err)
|
|
||||||
weCfg := decoded.(*config.WeComSettings)
|
|
||||||
weCfg.WebSocketURL = ""
|
|
||||||
|
|
||||||
applyWeComAuthResult(cfg, wecomQRBotInfo{
|
applyWeComAuthResult(cfg, wecomQRBotInfo{
|
||||||
BotID: "bot-1",
|
BotID: "bot-1",
|
||||||
Secret: "secret-1",
|
Secret: "secret-1",
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.True(t, wecom.Enabled)
|
assert.True(t, cfg.Channels.WeCom.Enabled)
|
||||||
assert.Equal(t, "bot-1", weCfg.BotID)
|
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
|
||||||
assert.Equal(t, "secret-1", weCfg.Secret.String())
|
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret.String())
|
||||||
assert.Equal(t, wecomDefaultWebSocketURL, weCfg.WebSocketURL)
|
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthWeComCmdWithScanner(t *testing.T) {
|
func TestAuthWeComCmdWithScanner(t *testing.T) {
|
||||||
|
|
@ -167,13 +149,9 @@ func TestAuthWeComCmdWithScanner(t *testing.T) {
|
||||||
|
|
||||||
cfg, err := config.LoadConfig(internal.GetConfigPath())
|
cfg, err := config.LoadConfig(internal.GetConfigPath())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
wecom := cfg.Channels["wecom"]
|
assert.True(t, cfg.Channels.WeCom.Enabled)
|
||||||
decoded, err := wecom.GetDecoded()
|
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
|
||||||
require.NoError(t, err)
|
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret.String())
|
||||||
weCfg := decoded.(*config.WeComSettings)
|
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
|
||||||
assert.True(t, wecom.Enabled)
|
|
||||||
assert.Equal(t, "bot-1", weCfg.BotID)
|
|
||||||
assert.Equal(t, "secret-1", weCfg.Secret.String())
|
|
||||||
assert.Equal(t, wecomDefaultWebSocketURL, weCfg.WebSocketURL)
|
|
||||||
assert.Contains(t, output.String(), "WeCom connected.")
|
assert.Contains(t, output.String(), "WeCom connected.")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,24 +95,14 @@ func saveWeixinConfig(token, baseURL, proxy string) error {
|
||||||
return fmt.Errorf("failed to load config: %w", err)
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bc := cfg.Channels.GetByType(config.ChannelWeixin)
|
cfg.Channels.Weixin.Enabled = true
|
||||||
if bc == nil {
|
cfg.Channels.Weixin.SetToken(token)
|
||||||
bc = &config.Channel{Type: config.ChannelWeixin}
|
const defaultBase = "https://ilinkai.weixin.qq.com/"
|
||||||
cfg.Channels[config.ChannelWeixin] = bc
|
if baseURL != "" && baseURL != defaultBase {
|
||||||
|
cfg.Channels.Weixin.BaseURL = baseURL
|
||||||
}
|
}
|
||||||
bc.Enabled = true
|
if proxy != "" {
|
||||||
|
cfg.Channels.Weixin.Proxy = proxy
|
||||||
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
|
|
||||||
if weixinCfg, ok := decoded.(*config.WeixinSettings); ok {
|
|
||||||
weixinCfg.Token = *config.NewSecureString(token)
|
|
||||||
const defaultBase = "https://ilinkai.weixin.qq.com/"
|
|
||||||
if baseURL != "" && baseURL != defaultBase {
|
|
||||||
weixinCfg.BaseURL = baseURL
|
|
||||||
}
|
|
||||||
if proxy != "" {
|
|
||||||
weixinCfg.Proxy = proxy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return config.SaveConfig(cfgPath, cfg)
|
return config.SaveConfig(cfgPath, cfg)
|
||||||
|
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
// Package cliui renders human-oriented CLI output: bordered panels and columns
|
|
||||||
// on wide interactive terminals. Layout (boxes/columns) is independent of ANSI
|
|
||||||
// color: use --no-color or NO_COLOR to disable colors only; narrow or non-TTY
|
|
||||||
// stdout falls back to plain line-oriented output.
|
|
||||||
package cliui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/muesli/termenv"
|
|
||||||
"golang.org/x/term"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Minimum terminal width (columns) for bordered / structured layout.
|
|
||||||
// Below this, plain line-oriented output is used so boxes do not wrap badly.
|
|
||||||
const minWidthFancy = 88
|
|
||||||
|
|
||||||
// Minimum width to lay out some views in two columns (e.g. status providers).
|
|
||||||
const minWidthColumns = 104
|
|
||||||
|
|
||||||
var initMu sync.Mutex
|
|
||||||
|
|
||||||
// Init configures lipgloss for this process. When disableAnsiColors is true
|
|
||||||
// (e.g. --no-color, NO_COLOR, or TERM=dumb), only color is turned off; Unicode
|
|
||||||
// borders still render when UseFancyLayout() is true.
|
|
||||||
func Init(disableAnsiColors bool) {
|
|
||||||
initMu.Lock()
|
|
||||||
defer initMu.Unlock()
|
|
||||||
if disableAnsiColors {
|
|
||||||
lipgloss.SetColorProfile(termenv.Ascii)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lipgloss.SetColorProfile(termenv.EnvColorProfile())
|
|
||||||
}
|
|
||||||
|
|
||||||
// StdoutWidth returns the terminal width or a sane default if unknown.
|
|
||||||
func StdoutWidth() int {
|
|
||||||
w, _, err := term.GetSize(int(os.Stdout.Fd()))
|
|
||||||
if err != nil || w < 20 {
|
|
||||||
return 80
|
|
||||||
}
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// UseFancyLayout is true when styled boxes/columns should be used.
|
|
||||||
func UseFancyLayout() bool {
|
|
||||||
if !term.IsTerminal(int(os.Stdout.Fd())) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return StdoutWidth() >= minWidthFancy
|
|
||||||
}
|
|
||||||
|
|
||||||
// UseColumnLayout is true when a second content column is viable.
|
|
||||||
func UseColumnLayout() bool {
|
|
||||||
return UseFancyLayout() && StdoutWidth() >= minWidthColumns
|
|
||||||
}
|
|
||||||
|
|
||||||
// InnerWidth is the target content width inside borders/margins.
|
|
||||||
func InnerWidth() int {
|
|
||||||
w := StdoutWidth()
|
|
||||||
// Rounded border + horizontal padding (lipgloss borders ~= 2 cols each side + padding).
|
|
||||||
const borderBudget = 8
|
|
||||||
if w > borderBudget+48 {
|
|
||||||
return w - borderBudget
|
|
||||||
}
|
|
||||||
return 48
|
|
||||||
}
|
|
||||||
|
|
||||||
// StderrWidth returns stderr terminal width or a sane default.
|
|
||||||
func StderrWidth() int {
|
|
||||||
w, _, err := term.GetSize(int(os.Stderr.Fd()))
|
|
||||||
if err != nil || w < 20 {
|
|
||||||
return 80
|
|
||||||
}
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// UseFancyStderr is true when stderr can show boxed errors without ugly wraps.
|
|
||||||
func UseFancyStderr() bool {
|
|
||||||
if !term.IsTerminal(int(os.Stderr.Fd())) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return StderrWidth() >= minWidthFancy
|
|
||||||
}
|
|
||||||
|
|
||||||
// InnerStderrWidth mirrors InnerWidth but for stderr.
|
|
||||||
func InnerStderrWidth() int {
|
|
||||||
w := StderrWidth()
|
|
||||||
const borderBudget = 8
|
|
||||||
if w > borderBudget+48 {
|
|
||||||
return w - borderBudget
|
|
||||||
}
|
|
||||||
return 48
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
accentBlue = lipgloss.Color("#3E5DB9")
|
|
||||||
accentRed = lipgloss.Color("#D54646")
|
|
||||||
colorMuted = lipgloss.Color("#6B6B6B")
|
|
||||||
colorOK = lipgloss.Color("#2E7D32")
|
|
||||||
)
|
|
||||||
|
|
||||||
func borderStyle() lipgloss.Style {
|
|
||||||
return lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.RoundedBorder()).
|
|
||||||
BorderForeground(accentBlue).
|
|
||||||
Padding(0, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func titleBarStyle() lipgloss.Style {
|
|
||||||
return lipgloss.NewStyle().
|
|
||||||
Foreground(accentRed).
|
|
||||||
Bold(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mutedStyle() lipgloss.Style {
|
|
||||||
return lipgloss.NewStyle().Foreground(colorMuted)
|
|
||||||
}
|
|
||||||
|
|
||||||
func bodyStyle() lipgloss.Style {
|
|
||||||
return lipgloss.NewStyle()
|
|
||||||
}
|
|
||||||
|
|
||||||
func kvKeyStyle() lipgloss.Style {
|
|
||||||
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func kvValStyle() lipgloss.Style {
|
|
||||||
return lipgloss.NewStyle()
|
|
||||||
}
|
|
||||||
|
|
||||||
// helpIntroStyle is the top tagline (PicoClaw blue, matches ASCII banner left side).
|
|
||||||
func helpIntroStyle() lipgloss.Style {
|
|
||||||
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// helpIdentStyle is the left column for commands and flags (blue identifiers).
|
|
||||||
func helpIdentStyle() lipgloss.Style {
|
|
||||||
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// helpPlaceholderStyle highlights <placeholders> in usage lines (red accent).
|
|
||||||
func helpPlaceholderStyle() lipgloss.Style {
|
|
||||||
return lipgloss.NewStyle().Foreground(accentRed).Bold(true)
|
|
||||||
}
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
package cliui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
flag "github.com/spf13/pflag"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
// Disable ANSI colors in tests so output is predictable plain text.
|
|
||||||
Init(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// showErrHint
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestShowErrHint(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
msg string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
// Cobra flag errors — should show hint
|
|
||||||
{"unknown flag: --foo", true},
|
|
||||||
{"unknown shorthand flag: 'f' in -f", true},
|
|
||||||
{"flag needs an argument: --output", true},
|
|
||||||
{"required flag(s) \"model\" not set", true},
|
|
||||||
// Generic invalid-argument errors — should show hint
|
|
||||||
{"invalid argument \"abc\" for --count", true},
|
|
||||||
// required flag errors — should show hint
|
|
||||||
{"required flag(s) \"model\" not set", true},
|
|
||||||
// usage: in message — should show hint
|
|
||||||
{"bad input\nusage: picoclaw ...", true},
|
|
||||||
// Should NOT false-positive on broad words
|
|
||||||
{"connection flagged by remote", false},
|
|
||||||
{"feature flag not set", false},
|
|
||||||
{"invalid API key provided", false},
|
|
||||||
{"authentication required", false},
|
|
||||||
// Unrelated messages — no hint
|
|
||||||
{"something went wrong", false},
|
|
||||||
{"network timeout", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
got := showErrHint(tc.msg)
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("showErrHint(%q) = %v, want %v", tc.msg, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// styleUsageTokens
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestStyleUsageTokensContainsTokens(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
input string
|
|
||||||
contains []string // substrings that must appear in plain output
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"picoclaw agent <message>",
|
|
||||||
[]string{"picoclaw agent", "<message>"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"picoclaw [command] [flags]",
|
|
||||||
[]string{"picoclaw", "[command]", "[flags]"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"picoclaw",
|
|
||||||
[]string{"picoclaw"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cmd <arg1> [--flag]",
|
|
||||||
[]string{"cmd", "<arg1>", "[--flag]"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
out := styleUsageTokens(tc.input)
|
|
||||||
for _, sub := range tc.contains {
|
|
||||||
if !containsStripped(out, sub) {
|
|
||||||
t.Errorf("styleUsageTokens(%q): output %q does not contain %q", tc.input, out, sub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// containsStripped checks whether plain contains sub after stripping ANSI escapes.
|
|
||||||
// Since Init(true) sets Ascii profile, lipgloss emits no escape codes in tests,
|
|
||||||
// so this is just a plain substring check.
|
|
||||||
func containsStripped(plain, sub string) bool {
|
|
||||||
return len(plain) >= len(sub) && findSubstring(plain, sub)
|
|
||||||
}
|
|
||||||
|
|
||||||
func findSubstring(s, sub string) bool {
|
|
||||||
for i := 0; i <= len(s)-len(sub); i++ {
|
|
||||||
if s[i:i+len(sub)] == sub {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// collectFlagRows
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestCollectFlagRows_Empty(t *testing.T) {
|
|
||||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
|
||||||
rows := collectFlagRows(fs)
|
|
||||||
if len(rows) != 0 {
|
|
||||||
t.Fatalf("expected 0 rows for empty FlagSet, got %d", len(rows))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCollectFlagRows_BasicFlags(t *testing.T) {
|
|
||||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
|
||||||
fs.String("output", "", "output file path")
|
|
||||||
fs.Bool("verbose", false, "enable verbose mode")
|
|
||||||
fs.Int("count", 1, "number of items")
|
|
||||||
|
|
||||||
rows := collectFlagRows(fs)
|
|
||||||
|
|
||||||
if len(rows) != 3 {
|
|
||||||
t.Fatalf("expected 3 rows, got %d", len(rows))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rows must be sorted alphabetically by flag name.
|
|
||||||
names := make([]string, 0, len(rows))
|
|
||||||
for _, r := range rows {
|
|
||||||
names = append(names, r[0])
|
|
||||||
}
|
|
||||||
if names[0] > names[1] || names[1] > names[2] {
|
|
||||||
t.Errorf("rows not sorted: %v", names)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCollectFlagRows_Shorthand(t *testing.T) {
|
|
||||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
|
||||||
fs.StringP("model", "m", "", "model name")
|
|
||||||
|
|
||||||
rows := collectFlagRows(fs)
|
|
||||||
if len(rows) != 1 {
|
|
||||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
|
||||||
}
|
|
||||||
left := rows[0][0]
|
|
||||||
if !findSubstring(left, "-m") || !findSubstring(left, "--model") {
|
|
||||||
t.Errorf("expected shorthand and long form in %q", left)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCollectFlagRows_HiddenFlagsExcluded(t *testing.T) {
|
|
||||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
|
||||||
fs.String("visible", "", "this shows up")
|
|
||||||
hidden := fs.String("hidden", "", "this should not show up")
|
|
||||||
_ = hidden
|
|
||||||
_ = fs.MarkHidden("hidden")
|
|
||||||
|
|
||||||
rows := collectFlagRows(fs)
|
|
||||||
if len(rows) != 1 {
|
|
||||||
t.Fatalf("expected 1 row (hidden excluded), got %d", len(rows))
|
|
||||||
}
|
|
||||||
if !findSubstring(rows[0][0], "visible") {
|
|
||||||
t.Errorf("expected visible flag in rows, got %q", rows[0][0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCollectFlagRows_UsageInRightColumn(t *testing.T) {
|
|
||||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
|
||||||
fs.String("format", "json", "output format: json or text")
|
|
||||||
|
|
||||||
rows := collectFlagRows(fs)
|
|
||||||
if len(rows) != 1 {
|
|
||||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if rows[0][1] != "output format: json or text" {
|
|
||||||
t.Errorf("expected usage in right column, got %q", rows[0][1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,298 +0,0 @@
|
||||||
package cliui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
flag "github.com/spf13/pflag"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RenderCommandHelp builds Ruff-style sectioned, two-column help when
|
|
||||||
// UseFancyLayout(); otherwise plain Cobra-style text.
|
|
||||||
func RenderCommandHelp(c *cobra.Command) string {
|
|
||||||
if !UseFancyLayout() {
|
|
||||||
return plainCommandHelp(c)
|
|
||||||
}
|
|
||||||
syncFlags(c)
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
head, sub := helpIntro(c)
|
|
||||||
if head != "" {
|
|
||||||
b.WriteString(helpIntroStyle().Render(head))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
if sub != "" {
|
|
||||||
b.WriteString(mutedStyle().Render(sub))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
if head != "" || sub != "" {
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
inner := InnerWidth()
|
|
||||||
contentW := inner - 6
|
|
||||||
if contentW < 36 {
|
|
||||||
contentW = 36
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine()))
|
|
||||||
b.WriteString(sectionPanel("Usage", usageBody, inner))
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// Examples
|
|
||||||
if ex := strings.TrimSpace(c.Example); ex != "" {
|
|
||||||
exBody := bodyStyle().Width(contentW).Render(ex)
|
|
||||||
b.WriteString(sectionPanel("Examples", exBody, inner))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subcommands
|
|
||||||
subs := visibleSubcommands(c)
|
|
||||||
if len(subs) > 0 {
|
|
||||||
rows := make([][2]string, 0, len(subs))
|
|
||||||
for _, sub := range subs {
|
|
||||||
left := sub.Name()
|
|
||||||
if a := sub.Aliases; len(a) > 0 {
|
|
||||||
left += " (" + strings.Join(a, ", ") + ")"
|
|
||||||
}
|
|
||||||
rows = append(rows, [2]string{left, sub.Short})
|
|
||||||
}
|
|
||||||
b.WriteString(sectionPanel("Commands", renderTwoColPairs(rows, contentW), inner))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local options
|
|
||||||
local := c.LocalFlags()
|
|
||||||
opts := collectFlagRows(local)
|
|
||||||
if len(opts) > 0 {
|
|
||||||
title := "Options"
|
|
||||||
if !c.HasParent() {
|
|
||||||
title = "Flags"
|
|
||||||
}
|
|
||||||
b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), inner))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global (inherited) options
|
|
||||||
if c.HasAvailableInheritedFlags() {
|
|
||||||
inh := collectFlagRows(c.InheritedFlags())
|
|
||||||
if len(inh) > 0 {
|
|
||||||
b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), inner))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderCommandQuickRef prints the same Usage / Flags / Global sections as help,
|
|
||||||
// for embedding after errors (stderr). outerW is typically InnerStderrWidth().
|
|
||||||
func RenderCommandQuickRef(c *cobra.Command, outerW int) string {
|
|
||||||
if c == nil || outerW < 40 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
syncFlags(c)
|
|
||||||
contentW := outerW - 6
|
|
||||||
if contentW < 36 {
|
|
||||||
contentW = 36
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine()))
|
|
||||||
b.WriteString(sectionPanel("Usage", usageBody, outerW))
|
|
||||||
b.WriteString("\n")
|
|
||||||
if len(c.Aliases) > 0 {
|
|
||||||
al := "Aliases: " + strings.Join(c.Aliases, ", ")
|
|
||||||
alBody := mutedStyle().MaxWidth(contentW).Render(al)
|
|
||||||
b.WriteString(sectionPanel("Aliases", alBody, outerW))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
opts := collectFlagRows(c.LocalFlags())
|
|
||||||
if len(opts) > 0 {
|
|
||||||
title := "Options"
|
|
||||||
if !c.HasParent() {
|
|
||||||
title = "Flags"
|
|
||||||
}
|
|
||||||
b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), outerW))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
if c.HasAvailableInheritedFlags() {
|
|
||||||
inh := collectFlagRows(c.InheritedFlags())
|
|
||||||
if len(inh) > 0 {
|
|
||||||
b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), outerW))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func syncFlags(c *cobra.Command) {
|
|
||||||
_ = c.LocalFlags()
|
|
||||||
if c.HasAvailableInheritedFlags() {
|
|
||||||
_ = c.InheritedFlags()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func plainCommandHelp(c *cobra.Command) string {
|
|
||||||
desc := c.Long
|
|
||||||
if desc == "" {
|
|
||||||
desc = c.Short
|
|
||||||
}
|
|
||||||
desc = strings.TrimRight(desc, " \t\n\r")
|
|
||||||
var b strings.Builder
|
|
||||||
if desc != "" {
|
|
||||||
fmt.Fprintln(&b, desc)
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
}
|
|
||||||
if c.Runnable() || c.HasSubCommands() {
|
|
||||||
b.WriteString(c.UsageString())
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func helpIntro(c *cobra.Command) (head, sub string) {
|
|
||||||
head = strings.TrimSpace(c.Short)
|
|
||||||
long := strings.TrimSpace(c.Long)
|
|
||||||
if long == "" || long == head {
|
|
||||||
return head, ""
|
|
||||||
}
|
|
||||||
lines := strings.Split(long, "\n")
|
|
||||||
var rest []string
|
|
||||||
for i, ln := range lines {
|
|
||||||
ln = strings.TrimSpace(ln)
|
|
||||||
if ln == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if i == 0 && ln == head {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rest = append(rest, ln)
|
|
||||||
}
|
|
||||||
sub = strings.Join(rest, "\n")
|
|
||||||
return head, sub
|
|
||||||
}
|
|
||||||
|
|
||||||
func visibleSubcommands(c *cobra.Command) []*cobra.Command {
|
|
||||||
var out []*cobra.Command
|
|
||||||
for _, sub := range c.Commands() {
|
|
||||||
if sub.Hidden {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, sub)
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func sectionPanel(title, body string, width int) string {
|
|
||||||
head := titleBarStyle().Render(title) + "\n\n"
|
|
||||||
return borderStyle().Width(width).Render(head + body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// styleUsageTokens highlights PicoClaw-blue command tokens and red <placeholders>/[groups].
|
|
||||||
func styleUsageTokens(s string) string {
|
|
||||||
var b strings.Builder
|
|
||||||
for len(s) > 0 {
|
|
||||||
ia := strings.Index(s, "<")
|
|
||||||
ib := strings.Index(s, "[")
|
|
||||||
next, kind := -1, 0 // 1 = angle, 2 = bracket
|
|
||||||
switch {
|
|
||||||
case ia >= 0 && (ib < 0 || ia < ib):
|
|
||||||
next, kind = ia, 1
|
|
||||||
case ib >= 0:
|
|
||||||
next, kind = ib, 2
|
|
||||||
}
|
|
||||||
if next < 0 {
|
|
||||||
b.WriteString(helpIdentStyle().Render(s))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if next > 0 {
|
|
||||||
b.WriteString(helpIdentStyle().Render(s[:next]))
|
|
||||||
}
|
|
||||||
s = s[next:]
|
|
||||||
if kind == 1 {
|
|
||||||
j := strings.Index(s, ">")
|
|
||||||
if j < 0 {
|
|
||||||
b.WriteString(helpIdentStyle().Render(s))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
b.WriteString(helpPlaceholderStyle().Render(s[:j+1]))
|
|
||||||
s = s[j+1:]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
j := strings.Index(s, "]")
|
|
||||||
if j < 0 {
|
|
||||||
b.WriteString(helpIdentStyle().Render(s))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
b.WriteString(helpPlaceholderStyle().Render(s[:j+1]))
|
|
||||||
s = s[j+1:]
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectFlagRows(fs *flag.FlagSet) [][2]string {
|
|
||||||
var names []string
|
|
||||||
seen := map[string][2]string{}
|
|
||||||
fs.VisitAll(func(f *flag.Flag) {
|
|
||||||
if f.Hidden {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
left := formatFlagLeft(f)
|
|
||||||
right := f.Usage
|
|
||||||
if f.Deprecated != "" {
|
|
||||||
right += " (deprecated: " + f.Deprecated + ")"
|
|
||||||
}
|
|
||||||
names = append(names, f.Name)
|
|
||||||
seen[f.Name] = [2]string{left, right}
|
|
||||||
})
|
|
||||||
sort.Strings(names)
|
|
||||||
rows := make([][2]string, 0, len(names))
|
|
||||||
for _, n := range names {
|
|
||||||
rows = append(rows, seen[n])
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatFlagLeft(f *flag.Flag) string {
|
|
||||||
if len(f.Shorthand) > 0 {
|
|
||||||
return "-" + f.Shorthand + ", --" + f.Name
|
|
||||||
}
|
|
||||||
return "--" + f.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderTwoColPairs(rows [][2]string, contentW int) string {
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
leftW := 0
|
|
||||||
for _, r := range rows {
|
|
||||||
if w := lipgloss.Width(r[0]); w > leftW {
|
|
||||||
leftW = w
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const minLeft, maxLeft = 16, 34
|
|
||||||
if leftW < minLeft {
|
|
||||||
leftW = minLeft
|
|
||||||
}
|
|
||||||
if leftW > maxLeft {
|
|
||||||
leftW = maxLeft
|
|
||||||
}
|
|
||||||
gap := " "
|
|
||||||
rightW := contentW - leftW - lipgloss.Width(gap)
|
|
||||||
if rightW < 24 {
|
|
||||||
rightW = 24
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
for _, r := range rows {
|
|
||||||
left := helpIdentStyle().Width(leftW).Align(lipgloss.Left).Render(r[0])
|
|
||||||
right := bodyStyle().Width(rightW).Render(strings.TrimSpace(r[1]))
|
|
||||||
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, left, gap, right))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
return strings.TrimRight(b.String(), "\n")
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
package cliui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FormatCLIError formats errors with the same boxed sections as help. When ctx
|
|
||||||
// is the command that was running when the error occurred, Usage / Flags panels
|
|
||||||
// are appended so styling matches picoclaw -h.
|
|
||||||
func FormatCLIError(msg string, ctx *cobra.Command) string {
|
|
||||||
msg = strings.TrimRight(msg, "\n")
|
|
||||||
if !UseFancyStderr() {
|
|
||||||
s := "Error: " + msg + "\n"
|
|
||||||
if ctx != nil && showErrHint(msg) {
|
|
||||||
s += "\n" + plainCommandHelp(ctx)
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
w := InnerStderrWidth()
|
|
||||||
contentW := w - 6
|
|
||||||
if contentW < 36 {
|
|
||||||
contentW = 36
|
|
||||||
}
|
|
||||||
|
|
||||||
title := titleBarStyle().Render("Error") + "\n\n"
|
|
||||||
|
|
||||||
paras := strings.Split(msg, "\n")
|
|
||||||
var body strings.Builder
|
|
||||||
for i, p := range paras {
|
|
||||||
p = strings.TrimRight(p, " ")
|
|
||||||
if p == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
st := bodyStyle().Width(contentW)
|
|
||||||
if i > 0 {
|
|
||||||
body.WriteString("\n")
|
|
||||||
}
|
|
||||||
if i == 0 {
|
|
||||||
body.WriteString(st.Render(p))
|
|
||||||
} else {
|
|
||||||
body.WriteString(mutedStyle().Width(contentW).Render(p))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foot := ""
|
|
||||||
if showErrHint(msg) {
|
|
||||||
if ctx != nil {
|
|
||||||
foot = "\n\n" + mutedStyle().Width(contentW).
|
|
||||||
Render("Full command help: "+ctx.CommandPath()+" --help")
|
|
||||||
} else {
|
|
||||||
foot = "\n\n" + mutedStyle().Width(contentW).
|
|
||||||
Render("Tip: picoclaw --help · picoclaw <command> --help")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := borderStyle().Width(w).Render(title+body.String()+foot) + "\n"
|
|
||||||
if ctx != nil && showErrHint(msg) {
|
|
||||||
if ref := RenderCommandQuickRef(ctx, w); ref != "" {
|
|
||||||
out += "\n" + ref
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func showErrHint(msg string) bool {
|
|
||||||
m := strings.ToLower(msg)
|
|
||||||
return strings.Contains(m, "unknown flag") ||
|
|
||||||
strings.Contains(m, "unknown shorthand flag") ||
|
|
||||||
strings.Contains(m, "flag needs an argument") ||
|
|
||||||
strings.Contains(m, "invalid argument") ||
|
|
||||||
strings.Contains(m, "required flag") ||
|
|
||||||
strings.Contains(m, "usage:")
|
|
||||||
}
|
|
||||||
|
|
@ -1,384 +0,0 @@
|
||||||
package cliui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MCPShowServer holds the server metadata for PrintMCPShow.
|
|
||||||
type MCPShowServer struct {
|
|
||||||
Name string
|
|
||||||
Type string
|
|
||||||
Target string
|
|
||||||
Enabled bool
|
|
||||||
EffectiveDeferred bool // resolved value (per-server override or global default)
|
|
||||||
DeferredExplicit bool // true = per-server override set, false = inherited from global
|
|
||||||
EnvKeys []string // sorted env var names (values intentionally omitted)
|
|
||||||
EnvFile string
|
|
||||||
Headers []string // sorted header names
|
|
||||||
}
|
|
||||||
|
|
||||||
// MCPShowTool holds one tool's info for PrintMCPShow.
|
|
||||||
type MCPShowTool struct {
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
Parameters []MCPShowParam
|
|
||||||
}
|
|
||||||
|
|
||||||
// MCPShowParam is one parameter entry.
|
|
||||||
type MCPShowParam struct {
|
|
||||||
Name string
|
|
||||||
Type string
|
|
||||||
Description string
|
|
||||||
Required bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// PrintMCPShow renders the mcp show output (plain or fancy).
|
|
||||||
// w is where the output is written; pass cmd.OutOrStdout() from cobra commands.
|
|
||||||
func PrintMCPShow(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) {
|
|
||||||
if !UseFancyLayout() {
|
|
||||||
printMCPShowPlain(w, server, tools, disabled)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
printMCPShowFancy(w, server, tools, disabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── plain (narrow / non-TTY) ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func printMCPShowPlain(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) {
|
|
||||||
fmt.Fprintf(w, "Server: %s\n", server.Name)
|
|
||||||
fmt.Fprintf(w, "Type: %s\n", server.Type)
|
|
||||||
fmt.Fprintf(w, "Target: %s\n", server.Target)
|
|
||||||
fmt.Fprintf(w, "Enabled: %s\n", boolWord(server.Enabled))
|
|
||||||
deferredLabel := boolWord(server.EffectiveDeferred)
|
|
||||||
if !server.DeferredExplicit {
|
|
||||||
deferredLabel += " (default)"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "Deferred: %s\n", deferredLabel)
|
|
||||||
if len(server.EnvKeys) > 0 {
|
|
||||||
fmt.Fprintf(w, "Env vars: %s\n", strings.Join(server.EnvKeys, ", "))
|
|
||||||
}
|
|
||||||
if server.EnvFile != "" {
|
|
||||||
fmt.Fprintf(w, "Env file: %s\n", server.EnvFile)
|
|
||||||
}
|
|
||||||
if len(server.Headers) > 0 {
|
|
||||||
fmt.Fprintf(w, "Headers: %s\n", strings.Join(server.Headers, ", "))
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
|
|
||||||
if disabled {
|
|
||||||
fmt.Fprintln(w, "Server is disabled; skipping tool discovery.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(tools) == 0 {
|
|
||||||
fmt.Fprintln(w, "No tools exposed by this server.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(w, "Tools (%d):\n", len(tools))
|
|
||||||
for _, tool := range tools {
|
|
||||||
fmt.Fprintf(w, " %s\n", tool.Name)
|
|
||||||
if tool.Description != "" {
|
|
||||||
fmt.Fprintf(w, " %s\n", truncateDescription(tool.Description, 120))
|
|
||||||
}
|
|
||||||
if len(tool.Parameters) == 0 {
|
|
||||||
fmt.Fprintln(w, " Parameters: none")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, p := range tool.Parameters {
|
|
||||||
line := fmt.Sprintf(" - %s", p.Name)
|
|
||||||
if p.Type != "" {
|
|
||||||
line += fmt.Sprintf(" (%s", p.Type)
|
|
||||||
if p.Required {
|
|
||||||
line += ", required"
|
|
||||||
}
|
|
||||||
line += ")"
|
|
||||||
} else if p.Required {
|
|
||||||
line += " (required)"
|
|
||||||
}
|
|
||||||
if p.Description != "" {
|
|
||||||
line += ": " + truncateDescription(p.Description, 80)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── fancy (wide TTY) ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
var (
|
|
||||||
mcpToolNameStyle = func() lipgloss.Style {
|
|
||||||
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
|
|
||||||
}
|
|
||||||
mcpParamNameStyle = func() lipgloss.Style {
|
|
||||||
return lipgloss.NewStyle().Foreground(accentRed).Bold(true)
|
|
||||||
}
|
|
||||||
mcpTagStyle = func() lipgloss.Style {
|
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
|
|
||||||
}
|
|
||||||
mcpRequiredStyle = func() lipgloss.Style {
|
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Bold(true)
|
|
||||||
}
|
|
||||||
mcpOptionalStyle = func() lipgloss.Style {
|
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B6B6B"))
|
|
||||||
}
|
|
||||||
mcpDescStyle = func() lipgloss.Style {
|
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC"))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func printMCPShowFancy(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) {
|
|
||||||
inner := InnerWidth()
|
|
||||||
box := borderStyle().Width(inner)
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
// ── server header ──
|
|
||||||
b.WriteString(titleBarStyle().Render("⬡ " + server.Name))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
keyW := 10
|
|
||||||
writeKV := func(key, val string) {
|
|
||||||
k := kvKeyStyle().Width(keyW).Render(key)
|
|
||||||
b.WriteString(k + " " + val + "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
writeKV("Type", server.Type)
|
|
||||||
writeKV("Target", server.Target)
|
|
||||||
writeKV("Enabled", coloredBool(server.Enabled))
|
|
||||||
deferredVal := coloredBool(server.EffectiveDeferred)
|
|
||||||
if !server.DeferredExplicit {
|
|
||||||
deferredVal += " " + mcpTagStyle().Render("(default)")
|
|
||||||
}
|
|
||||||
writeKV("Deferred", deferredVal)
|
|
||||||
if len(server.EnvKeys) > 0 {
|
|
||||||
writeKV("Env vars", mutedStyle().Render(strings.Join(server.EnvKeys, ", ")))
|
|
||||||
}
|
|
||||||
if server.EnvFile != "" {
|
|
||||||
writeKV("Env file", mutedStyle().Render(server.EnvFile))
|
|
||||||
}
|
|
||||||
if len(server.Headers) > 0 {
|
|
||||||
writeKV("Headers", mutedStyle().Render(strings.Join(server.Headers, ", ")))
|
|
||||||
}
|
|
||||||
|
|
||||||
if disabled {
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(mutedStyle().Render("Server is disabled; skipping tool discovery."))
|
|
||||||
fmt.Fprintln(w, box.Render(b.String()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tools) == 0 {
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(mutedStyle().Render("No tools exposed by this server."))
|
|
||||||
fmt.Fprintln(w, box.Render(b.String()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── tools section ──
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(kvKeyStyle().Render(fmt.Sprintf("Tools (%d)", len(tools))))
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
contentW := inner - 4 // account for box padding
|
|
||||||
for i, tool := range tools {
|
|
||||||
if i > 0 {
|
|
||||||
b.WriteString(strings.Repeat("─", contentW) + "\n")
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// Tool name + index badge
|
|
||||||
badge := mcpTagStyle().Render(fmt.Sprintf("[%d/%d]", i+1, len(tools)))
|
|
||||||
b.WriteString(" " + mcpToolNameStyle().Render(tool.Name) + " " + badge + "\n")
|
|
||||||
|
|
||||||
// Description (wrapped to content width)
|
|
||||||
if tool.Description != "" {
|
|
||||||
desc := truncateDescription(tool.Description, 160)
|
|
||||||
b.WriteString(" " + mcpDescStyle().Render(desc) + "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parameters
|
|
||||||
if len(tool.Parameters) == 0 {
|
|
||||||
b.WriteString(" " + mcpTagStyle().Render("no parameters") + "\n")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n")
|
|
||||||
for _, p := range tool.Parameters {
|
|
||||||
// name
|
|
||||||
pName := mcpParamNameStyle().Render(p.Name)
|
|
||||||
|
|
||||||
// type tag
|
|
||||||
typeTag := ""
|
|
||||||
if p.Type != "" {
|
|
||||||
typeTag = " " + mcpTagStyle().Render("<"+p.Type+">")
|
|
||||||
}
|
|
||||||
|
|
||||||
// required / optional badge
|
|
||||||
var reqBadge string
|
|
||||||
if p.Required {
|
|
||||||
reqBadge = " " + mcpRequiredStyle().Render("required")
|
|
||||||
} else {
|
|
||||||
reqBadge = " " + mcpOptionalStyle().Render("optional")
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString(" " + pName + typeTag + reqBadge + "\n")
|
|
||||||
|
|
||||||
if p.Description != "" {
|
|
||||||
desc := truncateDescription(p.Description, 120)
|
|
||||||
b.WriteString(" " + mutedStyle().Render(desc) + "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(w, box.Render(b.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── mcp list ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// MCPListRow is one row in the mcp list output.
|
|
||||||
type MCPListRow struct {
|
|
||||||
Name string
|
|
||||||
Type string
|
|
||||||
Target string
|
|
||||||
Status string // "enabled", "disabled", "ok (N tools)", "error"
|
|
||||||
EffectiveDeferred bool // resolved value (per-server override or global default)
|
|
||||||
DeferredExplicit bool // true = per-server override set, false = inherited from global
|
|
||||||
}
|
|
||||||
|
|
||||||
// PrintMCPList renders the mcp list output (plain or fancy).
|
|
||||||
func PrintMCPList(w io.Writer, rows []MCPListRow) {
|
|
||||||
if !UseFancyLayout() {
|
|
||||||
printMCPListPlain(w, rows)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
printMCPListFancy(w, rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func printMCPListPlain(w io.Writer, rows []MCPListRow) {
|
|
||||||
headers := []string{"Name", "Type", "Command", "Status", "Deferred"}
|
|
||||||
tableRows := make([][]string, len(rows))
|
|
||||||
for i, r := range rows {
|
|
||||||
deferred := boolWord(r.EffectiveDeferred)
|
|
||||||
if !r.DeferredExplicit {
|
|
||||||
deferred += " (default)"
|
|
||||||
}
|
|
||||||
tableRows[i] = []string{r.Name, r.Type, r.Target, r.Status, deferred}
|
|
||||||
}
|
|
||||||
// reuse the ASCII table renderer already in helpers.go via the caller
|
|
||||||
// (list.go still uses renderTable for the plain path)
|
|
||||||
widths := make([]int, len(headers))
|
|
||||||
for i, h := range headers {
|
|
||||||
widths[i] = len(h)
|
|
||||||
}
|
|
||||||
for _, row := range tableRows {
|
|
||||||
for i, cell := range row {
|
|
||||||
if len(cell) > widths[i] {
|
|
||||||
widths[i] = len(cell)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
border := func() {
|
|
||||||
fmt.Fprint(w, "+")
|
|
||||||
for _, width := range widths {
|
|
||||||
fmt.Fprint(w, strings.Repeat("-", width+2)+"+")
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
writeRow := func(row []string) {
|
|
||||||
fmt.Fprint(w, "|")
|
|
||||||
for i, cell := range row {
|
|
||||||
fmt.Fprintf(w, " %s%s |", cell, strings.Repeat(" ", widths[i]-len(cell)))
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
border()
|
|
||||||
writeRow(headers)
|
|
||||||
border()
|
|
||||||
for _, row := range tableRows {
|
|
||||||
writeRow(row)
|
|
||||||
}
|
|
||||||
border()
|
|
||||||
}
|
|
||||||
|
|
||||||
func printMCPListFancy(w io.Writer, rows []MCPListRow) {
|
|
||||||
inner := InnerWidth()
|
|
||||||
box := borderStyle().Width(inner)
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
title := fmt.Sprintf("MCP Servers (%d)", len(rows))
|
|
||||||
b.WriteString(titleBarStyle().Render(title))
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
contentW := inner - 4
|
|
||||||
for i, row := range rows {
|
|
||||||
if i > 0 {
|
|
||||||
b.WriteString(strings.Repeat("─", contentW) + "\n")
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
statusBadge := mcpListStatusStyle(row.Status).Render(row.Status)
|
|
||||||
var deferredBadge string
|
|
||||||
if row.EffectiveDeferred {
|
|
||||||
if row.DeferredExplicit {
|
|
||||||
deferredBadge = " " + mcpTagStyle().Render("deferred")
|
|
||||||
} else {
|
|
||||||
deferredBadge = " " + mcpOptionalStyle().Render("deferred (default)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteString(" " + mcpToolNameStyle().Render(row.Name) + " " + statusBadge + deferredBadge + "\n")
|
|
||||||
b.WriteString(" " + mcpTagStyle().Render(row.Type+" "+row.Target) + "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(w, box.Render(b.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpListStatusStyle(status string) lipgloss.Style {
|
|
||||||
switch {
|
|
||||||
case status == "enabled":
|
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true)
|
|
||||||
case status == "disabled":
|
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B6B6B"))
|
|
||||||
case strings.HasPrefix(status, "ok"):
|
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true)
|
|
||||||
case status == "error":
|
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Bold(true)
|
|
||||||
default:
|
|
||||||
return lipgloss.NewStyle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func boolWord(v bool) string {
|
|
||||||
if v {
|
|
||||||
return "yes"
|
|
||||||
}
|
|
||||||
return "no"
|
|
||||||
}
|
|
||||||
|
|
||||||
func coloredBool(v bool) string {
|
|
||||||
if v {
|
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true).Render("yes")
|
|
||||||
}
|
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Render("no")
|
|
||||||
}
|
|
||||||
|
|
||||||
// truncateDescription strips newlines, collapses whitespace, and caps length.
|
|
||||||
func truncateDescription(s string, maxLen int) string {
|
|
||||||
// collapse newlines and repeated spaces into a single space
|
|
||||||
s = strings.Join(strings.Fields(s), " ")
|
|
||||||
if len(s) <= maxLen {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
// cut at last space before maxLen
|
|
||||||
cut := s[:maxLen]
|
|
||||||
if idx := strings.LastIndex(cut, " "); idx > maxLen/2 {
|
|
||||||
cut = cut[:idx]
|
|
||||||
}
|
|
||||||
return cut + "…"
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
package cliui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PrintOnboardComplete prints the post-onboard “ready” message and next steps.
|
|
||||||
func PrintOnboardComplete(logo string, encrypt bool, configPath string) {
|
|
||||||
if !UseFancyLayout() {
|
|
||||||
printOnboardPlain(logo, encrypt, configPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
printOnboardFancy(logo, encrypt, configPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func printOnboardPlain(logo string, encrypt bool, configPath string) {
|
|
||||||
fmt.Printf("\n%s picoclaw is ready!\n", logo)
|
|
||||||
fmt.Println("\nNext steps:")
|
|
||||||
if encrypt {
|
|
||||||
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
|
|
||||||
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
|
|
||||||
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
|
|
||||||
fmt.Println("")
|
|
||||||
fmt.Println(" 2. Add your API key to", configPath)
|
|
||||||
} else {
|
|
||||||
fmt.Println(" 1. Add your API key to", configPath)
|
|
||||||
}
|
|
||||||
fmt.Println("")
|
|
||||||
fmt.Println(" Recommended:")
|
|
||||||
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
|
|
||||||
fmt.Println(" - Ollama: https://ollama.com (local, free)")
|
|
||||||
fmt.Println("")
|
|
||||||
fmt.Println(" See README.md for 17+ supported providers.")
|
|
||||||
fmt.Println("")
|
|
||||||
if encrypt {
|
|
||||||
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
|
|
||||||
} else {
|
|
||||||
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func printOnboardFancy(logo string, encrypt bool, configPath string) {
|
|
||||||
inner := InnerWidth()
|
|
||||||
box := borderStyle().MaxWidth(inner + 8)
|
|
||||||
|
|
||||||
ready := titleBarStyle().Render(logo+" picoclaw is ready!") + "\n"
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println(box.Width(inner).Render(strings.TrimSpace(ready)))
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
steps := buildOnboardingSteps(encrypt, configPath)
|
|
||||||
rec := recommendedBlock()
|
|
||||||
chat := chatStep(encrypt)
|
|
||||||
|
|
||||||
if UseColumnLayout() {
|
|
||||||
leftW := min(inner/2-2, 52)
|
|
||||||
rightW := inner - leftW - 4
|
|
||||||
if rightW < 36 {
|
|
||||||
rightW = 36
|
|
||||||
}
|
|
||||||
leftBlock := borderStyle().MaxWidth(leftW + 8).Width(leftW).
|
|
||||||
Render(titleBarStyle().Render("Next steps") + "\n\n" + bodyStyle().Width(leftW).Render(steps))
|
|
||||||
rightBlock := borderStyle().MaxWidth(rightW + 8).Width(rightW).
|
|
||||||
Render(mutedStyle().Bold(true).Render("Recommended") + "\n\n" + bodyStyle().Width(rightW).Render(rec))
|
|
||||||
gap := strings.Repeat(" ", 2)
|
|
||||||
fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, leftBlock, gap, rightBlock))
|
|
||||||
fmt.Println()
|
|
||||||
full := borderStyle().Width(inner).Render(bodyStyle().Width(inner - 4).Render(chat))
|
|
||||||
fmt.Println(full)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same order as plain output: numbered steps → recommended → chat line.
|
|
||||||
next := titleBarStyle().Render("Next steps") + "\n\n" +
|
|
||||||
bodyStyle().Width(inner-4).Render(steps+"\n\n"+rec+"\n\n"+chat)
|
|
||||||
fmt.Println(borderStyle().Width(inner).Render(next))
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildOnboardingSteps(encrypt bool, configPath string) string {
|
|
||||||
var b strings.Builder
|
|
||||||
if encrypt {
|
|
||||||
b.WriteString("1. Set your encryption passphrase before starting picoclaw:\n")
|
|
||||||
b.WriteString(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS\n")
|
|
||||||
b.WriteString(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd\n\n")
|
|
||||||
b.WriteString("2. Add your API key to\n ")
|
|
||||||
b.WriteString(configPath)
|
|
||||||
b.WriteString("\n")
|
|
||||||
} else {
|
|
||||||
b.WriteString("1. Add your API key to\n ")
|
|
||||||
b.WriteString(configPath)
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func recommendedBlock() string {
|
|
||||||
return "• OpenRouter: https://openrouter.ai/keys\n (access 100+ models)\n\n" +
|
|
||||||
"• Ollama: https://ollama.com\n (local, free)\n\n" +
|
|
||||||
"See README.md for 17+ supported providers."
|
|
||||||
}
|
|
||||||
|
|
||||||
func chatStep(encrypt bool) string {
|
|
||||||
if encrypt {
|
|
||||||
return "3. Chat:\n picoclaw agent -m \"Hello!\""
|
|
||||||
}
|
|
||||||
return "2. Chat:\n picoclaw agent -m \"Hello!\""
|
|
||||||
}
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
package cliui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProviderRow holds one provider's display name and status value.
|
|
||||||
type ProviderRow struct {
|
|
||||||
Name string
|
|
||||||
Val string
|
|
||||||
}
|
|
||||||
|
|
||||||
// StatusReport is a structured status view for PrintStatus.
|
|
||||||
type StatusReport struct {
|
|
||||||
Logo string
|
|
||||||
Version string
|
|
||||||
Build string
|
|
||||||
ConfigPath string
|
|
||||||
ConfigOK bool
|
|
||||||
WorkspacePath string
|
|
||||||
WorkspaceOK bool
|
|
||||||
Model string
|
|
||||||
Providers []ProviderRow
|
|
||||||
OAuthLines []string // each full line "provider (method): state"
|
|
||||||
}
|
|
||||||
|
|
||||||
// PrintStatus renders picoclaw status (plain or fancy).
|
|
||||||
func PrintStatus(r StatusReport) {
|
|
||||||
if !UseFancyLayout() {
|
|
||||||
printStatusPlain(r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
printStatusFancy(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func printStatusPlain(r StatusReport) {
|
|
||||||
fmt.Printf("%s picoclaw Status\n", r.Logo)
|
|
||||||
fmt.Printf("Version: %s\n", r.Version)
|
|
||||||
if r.Build != "" {
|
|
||||||
fmt.Printf("Build: %s\n", r.Build)
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
printPathLine("Config", r.ConfigPath, r.ConfigOK)
|
|
||||||
printPathLine("Workspace", r.WorkspacePath, r.WorkspaceOK)
|
|
||||||
|
|
||||||
if r.ConfigOK {
|
|
||||||
fmt.Printf("Model: %s\n", r.Model)
|
|
||||||
for _, p := range r.Providers {
|
|
||||||
fmt.Printf("%s: %s\n", p.Name, p.Val)
|
|
||||||
}
|
|
||||||
if len(r.OAuthLines) > 0 {
|
|
||||||
fmt.Println("\nOAuth/Token Auth:")
|
|
||||||
for _, line := range r.OAuthLines {
|
|
||||||
fmt.Printf(" %s\n", line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func printPathLine(label, path string, ok bool) {
|
|
||||||
mark := "✗"
|
|
||||||
if ok {
|
|
||||||
mark = "✓"
|
|
||||||
}
|
|
||||||
fmt.Println(label+":", path, mark)
|
|
||||||
}
|
|
||||||
|
|
||||||
func printStatusFancy(r StatusReport) {
|
|
||||||
inner := InnerWidth()
|
|
||||||
topBox := borderStyle().Width(inner)
|
|
||||||
|
|
||||||
var head strings.Builder
|
|
||||||
head.WriteString(titleBarStyle().Render(r.Logo + " picoclaw Status"))
|
|
||||||
head.WriteString("\n\n")
|
|
||||||
head.WriteString(kvKeyStyle().Render("Version") + " " + kvValStyle().Render(r.Version))
|
|
||||||
if r.Build != "" {
|
|
||||||
head.WriteString("\n")
|
|
||||||
head.WriteString(kvKeyStyle().Render("Build") + " " + kvValStyle().Render(r.Build))
|
|
||||||
}
|
|
||||||
fmt.Println(topBox.Render(head.String()))
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
if UseColumnLayout() && len(r.Providers) > 0 && r.ConfigOK {
|
|
||||||
leftW := (inner - 2) / 2
|
|
||||||
rightW := inner - leftW - 2
|
|
||||||
pathsNarrow := pathStatusPanel(r, leftW)
|
|
||||||
prov := providerTablePanel(r, rightW)
|
|
||||||
gap := strings.Repeat(" ", 2)
|
|
||||||
fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, pathsNarrow, gap, prov))
|
|
||||||
} else {
|
|
||||||
fmt.Println(pathStatusPanel(r, inner))
|
|
||||||
if len(r.Providers) > 0 && r.ConfigOK {
|
|
||||||
fmt.Println(providerTablePanel(r, inner))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(r.OAuthLines) > 0 && r.ConfigOK {
|
|
||||||
var ob strings.Builder
|
|
||||||
ob.WriteString(titleBarStyle().Render("OAuth / token auth") + "\n\n")
|
|
||||||
for _, line := range r.OAuthLines {
|
|
||||||
ob.WriteString(" • " + line + "\n")
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println(borderStyle().Width(inner).Render(ob.String()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func pathStatusPanel(r StatusReport, inner int) string {
|
|
||||||
cfgMark := statusMark(r.ConfigOK)
|
|
||||||
wsMark := statusMark(r.WorkspaceOK)
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString(kvKeyStyle().Render("Config") + "\n")
|
|
||||||
b.WriteString(mutedStyle().Render(r.ConfigPath))
|
|
||||||
b.WriteString(" " + cfgMark + "\n\n")
|
|
||||||
b.WriteString(kvKeyStyle().Render("Workspace") + "\n")
|
|
||||||
b.WriteString(mutedStyle().Render(r.WorkspacePath))
|
|
||||||
b.WriteString(" " + wsMark + "\n")
|
|
||||||
if r.ConfigOK {
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(kvKeyStyle().Render("Model") + " " + kvValStyle().Render(r.Model))
|
|
||||||
}
|
|
||||||
return borderStyle().Width(inner).Render(b.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func statusMark(ok bool) string {
|
|
||||||
if ok {
|
|
||||||
return lipgloss.NewStyle().Foreground(colorOK).Render("✓")
|
|
||||||
}
|
|
||||||
return lipgloss.NewStyle().Foreground(accentRed).Render("✗")
|
|
||||||
}
|
|
||||||
|
|
||||||
func providerTablePanel(r StatusReport, colW int) string {
|
|
||||||
if len(r.Providers) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
keyW := min(22, colW/3)
|
|
||||||
if keyW < 14 {
|
|
||||||
keyW = 14
|
|
||||||
}
|
|
||||||
valW := colW - keyW - 3
|
|
||||||
if valW < 12 {
|
|
||||||
valW = 12
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString(titleBarStyle().Render("Providers & local") + "\n\n")
|
|
||||||
for _, p := range r.Providers {
|
|
||||||
k := lipgloss.NewStyle().Foreground(accentBlue).Bold(true).Width(keyW).Render(p.Name)
|
|
||||||
v := styleProviderVal(p.Val).Width(valW).Render(p.Val)
|
|
||||||
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, k, " ", v))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
return borderStyle().Width(colW).Render(strings.TrimRight(b.String(), "\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func styleProviderVal(s string) lipgloss.Style {
|
|
||||||
if s == "✓" || strings.HasPrefix(s, "✓ ") {
|
|
||||||
return lipgloss.NewStyle().Foreground(colorOK)
|
|
||||||
}
|
|
||||||
if s == "not set" {
|
|
||||||
return mutedStyle()
|
|
||||||
}
|
|
||||||
return lipgloss.NewStyle()
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
package cliui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PrintVersion prints version, optional build info, and Go toolchain line.
|
|
||||||
func PrintVersion(logo, versionLine string, build, goVer string) {
|
|
||||||
if !UseFancyLayout() {
|
|
||||||
fmt.Printf("%s %s\n", logo, versionLine)
|
|
||||||
if build != "" {
|
|
||||||
fmt.Printf(" Build: %s\n", build)
|
|
||||||
}
|
|
||||||
if goVer != "" {
|
|
||||||
fmt.Printf(" Go: %s\n", goVer)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
inner := InnerWidth()
|
|
||||||
box := borderStyle().Width(inner)
|
|
||||||
|
|
||||||
if UseColumnLayout() {
|
|
||||||
leftCol := kvKeyStyle().Width(12).Align(lipgloss.Right)
|
|
||||||
rightW := inner - 16
|
|
||||||
rightStyle := kvValStyle().Width(rightW)
|
|
||||||
|
|
||||||
rows := [][]string{
|
|
||||||
{leftCol.Render("Version"), rightStyle.Render(versionLine)},
|
|
||||||
}
|
|
||||||
if build != "" {
|
|
||||||
rows = append(rows, []string{leftCol.Render("Build"), rightStyle.Render(build)})
|
|
||||||
}
|
|
||||||
if goVer != "" {
|
|
||||||
rows = append(rows, []string{leftCol.Render("Go"), rightStyle.Render(goVer)})
|
|
||||||
}
|
|
||||||
var body strings.Builder
|
|
||||||
for _, r := range rows {
|
|
||||||
body.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, r[0], " ", r[1]))
|
|
||||||
body.WriteString("\n")
|
|
||||||
}
|
|
||||||
header := titleBarStyle().Render(logo+" picoclaw") + "\n\n"
|
|
||||||
fmt.Println(box.Render(header + body.String()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines []string
|
|
||||||
lines = append(lines, titleBarStyle().Render(logo+" picoclaw"))
|
|
||||||
lines = append(lines, "")
|
|
||||||
lines = append(lines, kvKeyStyle().Render("Version")+" "+kvValStyle().Render(versionLine))
|
|
||||||
if build != "" {
|
|
||||||
lines = append(lines, kvKeyStyle().Render("Build")+" "+kvValStyle().Render(build))
|
|
||||||
}
|
|
||||||
if goVer != "" {
|
|
||||||
lines = append(lines, kvKeyStyle().Render("Go")+" "+kvValStyle().Render(goVer))
|
|
||||||
}
|
|
||||||
fmt.Println(box.Render(strings.Join(lines, "\n")))
|
|
||||||
}
|
|
||||||
|
|
@ -2,34 +2,19 @@ package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
|
|
||||||
"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"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/gateway"
|
"github.com/sipeed/picoclaw/pkg/gateway"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/netbind"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func resolveGatewayHostOverride(explicit bool, host string) (string, error) {
|
|
||||||
if !explicit {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
normalized, err := netbind.NormalizeHostInput(host)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("invalid --host value: %w", err)
|
|
||||||
}
|
|
||||||
return normalized, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGatewayCommand() *cobra.Command {
|
func NewGatewayCommand() *cobra.Command {
|
||||||
var debug bool
|
var debug bool
|
||||||
var noTruncate bool
|
var noTruncate bool
|
||||||
var allowEmpty bool
|
var allowEmpty bool
|
||||||
var host string
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "gateway",
|
Use: "gateway",
|
||||||
|
|
@ -48,25 +33,7 @@ func NewGatewayCommand() *cobra.Command {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
resolvedHost, err := resolveGatewayHostOverride(cmd.Flags().Changed("host"), host)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if resolvedHost != "" {
|
|
||||||
prevHost, hadPrev := os.LookupEnv(config.EnvGatewayHost)
|
|
||||||
if err := os.Setenv(config.EnvGatewayHost, resolvedHost); err != nil {
|
|
||||||
return fmt.Errorf("failed to set %s: %w", config.EnvGatewayHost, err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if hadPrev {
|
|
||||||
_ = os.Setenv(config.EnvGatewayHost, prevHost)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = os.Unsetenv(config.EnvGatewayHost)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty)
|
return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -80,12 +47,6 @@ func NewGatewayCommand() *cobra.Command {
|
||||||
false,
|
false,
|
||||||
"Continue starting even when no default model is configured",
|
"Continue starting even when no default model is configured",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVar(
|
|
||||||
&host,
|
|
||||||
"host",
|
|
||||||
"",
|
|
||||||
"Host address for gateway binding (overrides gateway.host for this run)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,38 +29,4 @@ func TestNewGatewayCommand(t *testing.T) {
|
||||||
assert.True(t, cmd.HasFlags())
|
assert.True(t, cmd.HasFlags())
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
|
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("host"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveGatewayHostOverride(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
explicit bool
|
|
||||||
host string
|
|
||||||
wantHost string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{name: "implicit empty host is allowed", explicit: false, host: "", wantHost: "", wantErr: false},
|
|
||||||
{name: "explicit empty host rejected", explicit: true, host: " ", wantHost: "", wantErr: true},
|
|
||||||
{name: "explicit localhost kept", explicit: true, host: " localhost ", wantHost: "localhost", wantErr: false},
|
|
||||||
{
|
|
||||||
name: "explicit multi host normalized",
|
|
||||||
explicit: true,
|
|
||||||
host: " [::1] , 127.0.0.1 ",
|
|
||||||
wantHost: "::1,127.0.0.1",
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got, err := resolveGatewayHostOverride(tt.explicit, tt.host)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Fatalf("resolveGatewayHostOverride() err = %v, wantErr %t", err, tt.wantErr)
|
|
||||||
}
|
|
||||||
if got != tt.wantHost {
|
|
||||||
t.Fatalf("resolveGatewayHostOverride() host = %q, want %q", got, tt.wantHost)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
package mcp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
type addOptions struct {
|
|
||||||
Env []string
|
|
||||||
EnvFile string
|
|
||||||
Headers []string
|
|
||||||
Transport string
|
|
||||||
Force bool
|
|
||||||
Deferred *bool // nil = not set, true = deferred, false = not deferred
|
|
||||||
}
|
|
||||||
|
|
||||||
func newAddCommand() *cobra.Command {
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "add [flags] <name> <command-or-url> [args...]",
|
|
||||||
Short: "Add or update an MCP server",
|
|
||||||
DisableFlagParsing: true,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
opts, name, target, targetArgs, showHelp, err := parseAddArgs(args)
|
|
||||||
if showHelp {
|
|
||||||
return cmd.Help()
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := loadConfig()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if cfg.Tools.MCP.Servers == nil {
|
|
||||||
cfg.Tools.MCP.Servers = make(map[string]config.MCPServerConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, exists := cfg.Tools.MCP.Servers[name]; exists && !opts.Force {
|
|
||||||
var overwrite bool
|
|
||||||
|
|
||||||
overwrite, err = confirmOverwrite(cmd.InOrStdin(), cmd.OutOrStdout(), name)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to confirm overwrite: %w", err)
|
|
||||||
}
|
|
||||||
if !overwrite {
|
|
||||||
return fmt.Errorf("aborted: MCP server %q already exists", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server, err := buildServerConfig(target, targetArgs, opts)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Tools.MCP.Enabled = true
|
|
||||||
cfg.Tools.MCP.Servers[name] = server
|
|
||||||
|
|
||||||
if err := saveValidatedConfig(cfg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q saved.\n", name)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
flags := cmd.Flags()
|
|
||||||
flags.StringArrayP("env", "e", nil, "Environment variable in KEY=value format (repeatable, saved to config)")
|
|
||||||
flags.String("env-file", "", "Path to an env file for stdio servers (recommended for secrets)")
|
|
||||||
flags.StringArrayP("header", "H", nil, "HTTP header in 'Name: Value' or 'Name=Value' format (repeatable)")
|
|
||||||
flags.StringP("transport", "t", "stdio", "Transport type: stdio, http / streamable-http, or sse")
|
|
||||||
flags.BoolP("force", "f", false, "Overwrite an existing server without prompting")
|
|
||||||
flags.Bool("deferred", false, "Mark server as deferred (tools hidden until explicitly activated)")
|
|
||||||
flags.Bool("no-deferred", false, "Mark server as non-deferred (tools always active)")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseAddArgs(args []string) (addOptions, string, string, []string, bool, error) {
|
|
||||||
opts := addOptions{Transport: "stdio"}
|
|
||||||
var positional []string
|
|
||||||
serverArgs := make([]string, 0)
|
|
||||||
explicitCommand := make([]string, 0)
|
|
||||||
|
|
||||||
for i := 0; i < len(args); i++ {
|
|
||||||
arg := args[i]
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case arg == "--help" || arg == "-h":
|
|
||||||
return addOptions{}, "", "", nil, true, nil
|
|
||||||
case arg == "--":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
explicitCommand = append(explicitCommand, args[i+1:]...)
|
|
||||||
}
|
|
||||||
i = len(args)
|
|
||||||
case arg == "--force" || arg == "-f":
|
|
||||||
opts.Force = true
|
|
||||||
case arg == "--deferred":
|
|
||||||
t := true
|
|
||||||
opts.Deferred = &t
|
|
||||||
case arg == "--no-deferred":
|
|
||||||
f := false
|
|
||||||
opts.Deferred = &f
|
|
||||||
case arg == "--transport" || arg == "-t":
|
|
||||||
if i+1 >= len(args) {
|
|
||||||
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
opts.Transport = args[i]
|
|
||||||
case strings.HasPrefix(arg, "--transport="):
|
|
||||||
opts.Transport = strings.TrimPrefix(arg, "--transport=")
|
|
||||||
case arg == "--env" || arg == "-e":
|
|
||||||
if i+1 >= len(args) {
|
|
||||||
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
opts.Env = append(opts.Env, args[i])
|
|
||||||
case arg == "--env-file":
|
|
||||||
if i+1 >= len(args) {
|
|
||||||
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
opts.EnvFile = args[i]
|
|
||||||
case strings.HasPrefix(arg, "--env="):
|
|
||||||
opts.Env = append(opts.Env, strings.TrimPrefix(arg, "--env="))
|
|
||||||
case strings.HasPrefix(arg, "--env-file="):
|
|
||||||
opts.EnvFile = strings.TrimPrefix(arg, "--env-file=")
|
|
||||||
case arg == "--header" || arg == "-H":
|
|
||||||
if i+1 >= len(args) {
|
|
||||||
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
opts.Headers = append(opts.Headers, args[i])
|
|
||||||
case strings.HasPrefix(arg, "--header="):
|
|
||||||
opts.Headers = append(opts.Headers, strings.TrimPrefix(arg, "--header="))
|
|
||||||
case strings.HasPrefix(arg, "-") && len(positional) >= 2:
|
|
||||||
serverArgs = append(serverArgs, args[i:]...)
|
|
||||||
i = len(args)
|
|
||||||
default:
|
|
||||||
positional = append(positional, arg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(explicitCommand) > 0 {
|
|
||||||
if len(positional) != 1 {
|
|
||||||
return addOptions{}, "", "", nil, false, fmt.Errorf(
|
|
||||||
"usage: picoclaw mcp add [flags] <name> <command-or-url> [args...] or picoclaw mcp add [flags] <name> -- <command> [args...]",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if len(explicitCommand) == 0 {
|
|
||||||
return addOptions{}, "", "", nil, false, fmt.Errorf("missing stdio command after --")
|
|
||||||
}
|
|
||||||
return opts, positional[0], explicitCommand[0], explicitCommand[1:], false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(positional) < 2 {
|
|
||||||
return addOptions{}, "", "", nil, false, fmt.Errorf(
|
|
||||||
"usage: picoclaw mcp add [flags] <name> <command-or-url> [args...] or picoclaw mcp add [flags] <name> -- <command> [args...]",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
targetArgs := make([]string, 0, len(positional)-2+len(serverArgs))
|
|
||||||
targetArgs = append(targetArgs, positional[2:]...)
|
|
||||||
targetArgs = append(targetArgs, serverArgs...)
|
|
||||||
|
|
||||||
return opts, positional[0], positional[1], targetArgs, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildServerConfig(target string, args []string, opts addOptions) (config.MCPServerConfig, error) {
|
|
||||||
transport := config.NormalizeMCPTransportType(opts.Transport)
|
|
||||||
if transport == "" {
|
|
||||||
transport = "stdio"
|
|
||||||
}
|
|
||||||
switch transport {
|
|
||||||
case "stdio", "http", "sse":
|
|
||||||
default:
|
|
||||||
return config.MCPServerConfig{}, fmt.Errorf("unsupported transport %q", opts.Transport)
|
|
||||||
}
|
|
||||||
|
|
||||||
env, err := parseEnvAssignments(opts.Env)
|
|
||||||
if err != nil {
|
|
||||||
return config.MCPServerConfig{}, err
|
|
||||||
}
|
|
||||||
headers, err := parseHeaderAssignments(opts.Headers)
|
|
||||||
if err != nil {
|
|
||||||
return config.MCPServerConfig{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
server := config.MCPServerConfig{
|
|
||||||
Enabled: true,
|
|
||||||
Type: transport,
|
|
||||||
Deferred: opts.Deferred,
|
|
||||||
}
|
|
||||||
|
|
||||||
switch transport {
|
|
||||||
case "http", "sse":
|
|
||||||
if len(env) > 0 {
|
|
||||||
return config.MCPServerConfig{}, fmt.Errorf("--env can only be used with stdio transport")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(opts.EnvFile) != "" {
|
|
||||||
return config.MCPServerConfig{}, fmt.Errorf("--env-file can only be used with stdio transport")
|
|
||||||
}
|
|
||||||
if len(args) > 0 {
|
|
||||||
return config.MCPServerConfig{}, fmt.Errorf("%s transport does not accept command arguments", transport)
|
|
||||||
}
|
|
||||||
parsedURL, err := url.ParseRequestURI(target)
|
|
||||||
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
|
|
||||||
return config.MCPServerConfig{}, fmt.Errorf("invalid MCP URL %q", target)
|
|
||||||
}
|
|
||||||
server.URL = target
|
|
||||||
server.Headers = headers
|
|
||||||
return server, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(headers) > 0 {
|
|
||||||
return config.MCPServerConfig{}, fmt.Errorf("--header can only be used with http or sse transport")
|
|
||||||
}
|
|
||||||
|
|
||||||
if looksLikeRemoteURL(target) {
|
|
||||||
return config.MCPServerConfig{}, fmt.Errorf(
|
|
||||||
"target %q looks like a remote MCP URL, but transport is %q. Use --transport http or --transport sse",
|
|
||||||
target,
|
|
||||||
transport,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
command := target
|
|
||||||
commandArgs := append([]string(nil), args...)
|
|
||||||
|
|
||||||
if err := validateLocalCommandPath(target); err != nil {
|
|
||||||
return config.MCPServerConfig{}, err
|
|
||||||
}
|
|
||||||
if isLocalCommandPath(command) {
|
|
||||||
command = expandHomePath(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
server.Command = command
|
|
||||||
server.Args = commandArgs
|
|
||||||
server.Env = env
|
|
||||||
server.EnvFile = strings.TrimSpace(opts.EnvFile)
|
|
||||||
|
|
||||||
return server, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
package mcp
|
|
||||||
|
|
||||||
import "github.com/spf13/cobra"
|
|
||||||
|
|
||||||
func NewMCPCommand() *cobra.Command {
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "mcp",
|
|
||||||
Short: "Manage MCP server configuration",
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
||||||
return cmd.Help()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.AddCommand(
|
|
||||||
newAddCommand(),
|
|
||||||
newRemoveCommand(),
|
|
||||||
newListCommand(),
|
|
||||||
newEditCommand(),
|
|
||||||
newTestCommand(),
|
|
||||||
newShowCommand(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
@ -1,660 +0,0 @@
|
||||||
package mcp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewMCPCommand(t *testing.T) {
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
|
||||||
|
|
||||||
assert.Equal(t, "mcp", cmd.Use)
|
|
||||||
assert.Equal(t, "Manage MCP server configuration", cmd.Short)
|
|
||||||
assert.True(t, cmd.HasSubCommands())
|
|
||||||
|
|
||||||
allowedCommands := []string{
|
|
||||||
"add",
|
|
||||||
"remove",
|
|
||||||
"list",
|
|
||||||
"edit",
|
|
||||||
"test",
|
|
||||||
"show",
|
|
||||||
}
|
|
||||||
|
|
||||||
subcommands := cmd.Commands()
|
|
||||||
assert.Len(t, subcommands, len(allowedCommands))
|
|
||||||
|
|
||||||
for _, subcmd := range subcommands {
|
|
||||||
found := slices.Contains(allowedCommands, subcmd.Name())
|
|
||||||
assert.True(t, found, "unexpected subcommand %q", subcmd.Name())
|
|
||||||
assert.False(t, subcmd.Hidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPAddAddsGenericStdioServer(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
output, err := executeCommand(cmd, []string{
|
|
||||||
"add",
|
|
||||||
"sqlite",
|
|
||||||
"npx",
|
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-sqlite",
|
|
||||||
"--db",
|
|
||||||
"./mydb.db",
|
|
||||||
}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, output, `MCP server "sqlite" saved`)
|
|
||||||
|
|
||||||
cfg := readMCPConfig(t, configPath)
|
|
||||||
require.True(t, cfg.Tools.MCP.Enabled)
|
|
||||||
|
|
||||||
server, ok := cfg.Tools.MCP.Servers["sqlite"]
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.True(t, server.Enabled)
|
|
||||||
assert.Equal(t, "stdio", server.Type)
|
|
||||||
assert.Equal(t, "npx", server.Command)
|
|
||||||
assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"}, server.Args)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPAddSupportsHeadersAfterURL(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{
|
|
||||||
"add",
|
|
||||||
"apify",
|
|
||||||
"https://mcp.apify.com/",
|
|
||||||
"-t",
|
|
||||||
"http",
|
|
||||||
"--header",
|
|
||||||
"Authorization: Bearer OMITTED",
|
|
||||||
}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cfg := readMCPConfig(t, configPath)
|
|
||||||
server := cfg.Tools.MCP.Servers["apify"]
|
|
||||||
assert.Equal(t, "http", server.Type)
|
|
||||||
assert.Equal(t, "https://mcp.apify.com/", server.URL)
|
|
||||||
assert.Equal(t, map[string]string{"Authorization": "Bearer OMITTED"}, server.Headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPAddSupportsTransportBeforeName(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{
|
|
||||||
"add",
|
|
||||||
"--transport",
|
|
||||||
"sse",
|
|
||||||
"fiscal-ai",
|
|
||||||
"https://api.fiscal.ai/mcp/sse",
|
|
||||||
}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cfg := readMCPConfig(t, configPath)
|
|
||||||
server := cfg.Tools.MCP.Servers["fiscal-ai"]
|
|
||||||
assert.Equal(t, "sse", server.Type)
|
|
||||||
assert.Equal(t, "https://api.fiscal.ai/mcp/sse", server.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPAddSupportsExplicitStdioCommandAfterSeparator(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{
|
|
||||||
"add",
|
|
||||||
"--transport",
|
|
||||||
"stdio",
|
|
||||||
"--env",
|
|
||||||
"AIRTABLE_API_KEY=YOUR_KEY",
|
|
||||||
"airtable",
|
|
||||||
"--",
|
|
||||||
"npx",
|
|
||||||
"-y",
|
|
||||||
"airtable-mcp-server",
|
|
||||||
}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cfg := readMCPConfig(t, configPath)
|
|
||||||
server := cfg.Tools.MCP.Servers["airtable"]
|
|
||||||
assert.Equal(t, "stdio", server.Type)
|
|
||||||
assert.Equal(t, "npx", server.Command)
|
|
||||||
assert.Equal(t, []string{"-y", "airtable-mcp-server"}, server.Args)
|
|
||||||
assert.Equal(t, map[string]string{"AIRTABLE_API_KEY": "YOUR_KEY"}, server.Env)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPAddSupportsEnvFileForStdio(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{
|
|
||||||
"add",
|
|
||||||
"--env-file",
|
|
||||||
".env.mcp",
|
|
||||||
"filesystem",
|
|
||||||
"npx",
|
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-filesystem",
|
|
||||||
}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cfg := readMCPConfig(t, configPath)
|
|
||||||
server := cfg.Tools.MCP.Servers["filesystem"]
|
|
||||||
assert.Equal(t, "stdio", server.Type)
|
|
||||||
assert.Equal(t, "npx", server.Command)
|
|
||||||
assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-filesystem"}, server.Args)
|
|
||||||
assert.Equal(t, ".env.mcp", server.EnvFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPAddRejectsEnvFileForHTTP(t *testing.T) {
|
|
||||||
setupMCPConfigEnv(t)
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{
|
|
||||||
"add",
|
|
||||||
"--transport",
|
|
||||||
"http",
|
|
||||||
"--env-file",
|
|
||||||
".env.mcp",
|
|
||||||
"context7",
|
|
||||||
"https://mcp.context7.com/mcp",
|
|
||||||
}, "")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "--env-file can only be used with stdio transport")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPAddRejectsNonExecutableLocalCommand(t *testing.T) {
|
|
||||||
setupMCPConfigEnv(t)
|
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
localCmd := filepath.Join(tmpDir, "server.sh")
|
|
||||||
require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o644))
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{"add", "local", localCmd}, "")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "not executable")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPAddExpandsHomeInSavedLocalCommand(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
|
|
||||||
homeDir := t.TempDir()
|
|
||||||
t.Setenv("HOME", homeDir)
|
|
||||||
t.Setenv("USERPROFILE", homeDir)
|
|
||||||
|
|
||||||
localCmd := filepath.Join(homeDir, "bin", "my-mcp")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(localCmd), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o755))
|
|
||||||
|
|
||||||
tildeCmd := "~" + string(os.PathSeparator) + filepath.Join("bin", "my-mcp")
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{"add", "local-home", tildeCmd}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cfg := readMCPConfig(t, configPath)
|
|
||||||
server := cfg.Tools.MCP.Servers["local-home"]
|
|
||||||
assert.Equal(t, localCmd, server.Command)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPAddShowsClearErrorForRemoteURLWithoutTransport(t *testing.T) {
|
|
||||||
setupMCPConfigEnv(t)
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{"add", "apify", "https://mcp.apify.com/"}, "")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), `looks like a remote MCP URL`)
|
|
||||||
assert.Contains(t, err.Error(), `Use --transport http or --transport sse`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPAddOverwritePromptDecline(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
writeMCPConfig(t, configPath, &config.Config{
|
|
||||||
Tools: config.ToolsConfig{
|
|
||||||
MCP: config.MCPConfig{
|
|
||||||
ToolConfig: config.ToolConfig{Enabled: true},
|
|
||||||
Servers: map[string]config.MCPServerConfig{
|
|
||||||
"filesystem": {
|
|
||||||
Enabled: true,
|
|
||||||
Type: "stdio",
|
|
||||||
Command: "old",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
output, err := executeCommand(cmd, []string{"add", "filesystem", "new-command"}, "n\n")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, output, `Overwrite? [y/N]:`)
|
|
||||||
assert.Contains(t, err.Error(), "aborted")
|
|
||||||
|
|
||||||
cfg := readMCPConfig(t, configPath)
|
|
||||||
assert.Equal(t, "old", cfg.Tools.MCP.Servers["filesystem"].Command)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPAddOverwriteWithConfirmation(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
writeMCPConfig(t, configPath, &config.Config{
|
|
||||||
Tools: config.ToolsConfig{
|
|
||||||
MCP: config.MCPConfig{
|
|
||||||
ToolConfig: config.ToolConfig{Enabled: true},
|
|
||||||
Servers: map[string]config.MCPServerConfig{
|
|
||||||
"filesystem": {
|
|
||||||
Enabled: true,
|
|
||||||
Type: "stdio",
|
|
||||||
Command: "old",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{"add", "filesystem", "new-command"}, "y\n")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cfg := readMCPConfig(t, configPath)
|
|
||||||
assert.Equal(t, "new-command", cfg.Tools.MCP.Servers["filesystem"].Command)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPAddHTTPServer(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{
|
|
||||||
"add",
|
|
||||||
"context7",
|
|
||||||
"--transport",
|
|
||||||
"http",
|
|
||||||
"https://mcp.context7.com/mcp",
|
|
||||||
}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cfg := readMCPConfig(t, configPath)
|
|
||||||
server := cfg.Tools.MCP.Servers["context7"]
|
|
||||||
assert.Equal(t, "http", server.Type)
|
|
||||||
assert.Equal(t, "https://mcp.context7.com/mcp", server.URL)
|
|
||||||
assert.Empty(t, server.Command)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPAddSupportsStreamableHTTPAlias(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{
|
|
||||||
"add",
|
|
||||||
"context7",
|
|
||||||
"--transport",
|
|
||||||
"streamable-http",
|
|
||||||
"https://mcp.context7.com/mcp",
|
|
||||||
}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cfg := readMCPConfig(t, configPath)
|
|
||||||
server := cfg.Tools.MCP.Servers["context7"]
|
|
||||||
assert.Equal(t, "http", server.Type)
|
|
||||||
assert.Equal(t, "https://mcp.context7.com/mcp", server.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSaveValidatedConfigNormalizesStreamableHTTPAlias(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
|
|
||||||
cfg := config.DefaultConfig()
|
|
||||||
cfg.Tools.MCP.Enabled = true
|
|
||||||
cfg.Tools.MCP.Servers = map[string]config.MCPServerConfig{
|
|
||||||
"context7": {
|
|
||||||
Enabled: true,
|
|
||||||
Type: "streamable-http",
|
|
||||||
URL: "https://mcp.context7.com/mcp",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, saveValidatedConfig(cfg))
|
|
||||||
|
|
||||||
saved := readMCPConfig(t, configPath)
|
|
||||||
server := saved.Tools.MCP.Servers["context7"]
|
|
||||||
assert.Equal(t, "http", server.Type)
|
|
||||||
assert.Equal(t, "https://mcp.context7.com/mcp", server.URL)
|
|
||||||
assert.Equal(t, "streamable-http", cfg.Tools.MCP.Servers["context7"].Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPRemoveRemovesLastServerAndDisablesMCP(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
writeMCPConfig(t, configPath, &config.Config{
|
|
||||||
Tools: config.ToolsConfig{
|
|
||||||
MCP: config.MCPConfig{
|
|
||||||
ToolConfig: config.ToolConfig{Enabled: true},
|
|
||||||
Servers: map[string]config.MCPServerConfig{
|
|
||||||
"filesystem": {
|
|
||||||
Enabled: true,
|
|
||||||
Type: "stdio",
|
|
||||||
Command: "npx",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
output, err := executeCommand(cmd, []string{"remove", "filesystem"}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, output, `MCP server "filesystem" removed`)
|
|
||||||
|
|
||||||
cfg := readMCPConfig(t, configPath)
|
|
||||||
assert.False(t, cfg.Tools.MCP.Enabled)
|
|
||||||
assert.Empty(t, cfg.Tools.MCP.Servers)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPListPrintsTable(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
writeMCPConfig(t, configPath, &config.Config{
|
|
||||||
Tools: config.ToolsConfig{
|
|
||||||
MCP: config.MCPConfig{
|
|
||||||
ToolConfig: config.ToolConfig{Enabled: true},
|
|
||||||
Servers: map[string]config.MCPServerConfig{
|
|
||||||
"context7": {
|
|
||||||
Enabled: true,
|
|
||||||
Type: "http",
|
|
||||||
URL: "https://mcp.context7.com/mcp",
|
|
||||||
},
|
|
||||||
"filesystem": {
|
|
||||||
Enabled: false,
|
|
||||||
Type: "stdio",
|
|
||||||
Command: "npx",
|
|
||||||
Args: []string{"-y", "@modelcontextprotocol/server-filesystem", "/tmp"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
output, err := executeCommand(cmd, []string{"list"}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, output, "| Name")
|
|
||||||
assert.Contains(t, output, "context7")
|
|
||||||
assert.Contains(t, output, "filesystem")
|
|
||||||
assert.Contains(t, output, "https://mcp.context7.com/mcp")
|
|
||||||
assert.Contains(t, output, "disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPListWithStatusUsesProbe(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
writeMCPConfig(t, configPath, &config.Config{
|
|
||||||
Tools: config.ToolsConfig{
|
|
||||||
MCP: config.MCPConfig{
|
|
||||||
ToolConfig: config.ToolConfig{Enabled: true},
|
|
||||||
Servers: map[string]config.MCPServerConfig{
|
|
||||||
"filesystem": {
|
|
||||||
Enabled: true,
|
|
||||||
Type: "stdio",
|
|
||||||
Command: "npx",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
originalProbe := serverProbe
|
|
||||||
defer func() { serverProbe = originalProbe }()
|
|
||||||
serverProbe = func(_ context.Context, name string, server config.MCPServerConfig, workspacePath string) (probeResult, error) {
|
|
||||||
assert.Equal(t, "filesystem", name)
|
|
||||||
assert.Equal(t, readMCPConfig(t, configPath).WorkspacePath(), workspacePath)
|
|
||||||
assert.Equal(t, "npx", server.Command)
|
|
||||||
return probeResult{ToolCount: 3}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
output, err := executeCommand(cmd, []string{"list", "--status"}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, output, "ok (3 tools)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPEditUsesEditor(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
|
|
||||||
originalEditor := editorCommand
|
|
||||||
defer func() { editorCommand = originalEditor }()
|
|
||||||
|
|
||||||
var gotName string
|
|
||||||
var gotArgs []string
|
|
||||||
editorCommand = func(name string, args ...string) *exec.Cmd {
|
|
||||||
gotName = name
|
|
||||||
gotArgs = append([]string(nil), args...)
|
|
||||||
return exec.Command("sh", "-c", "exit 0")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("EDITOR", `dummy-editor --wait`)
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{"edit"}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, "dummy-editor", gotName)
|
|
||||||
assert.Equal(t, []string{"--wait", configPath}, gotArgs)
|
|
||||||
_, statErr := os.Stat(configPath)
|
|
||||||
assert.NoError(t, statErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPEditRequiresEditor(t *testing.T) {
|
|
||||||
setupMCPConfigEnv(t)
|
|
||||||
t.Setenv("EDITOR", "")
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{"edit"}, "")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "$EDITOR is not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPTestUsesProbe(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
writeMCPConfig(t, configPath, &config.Config{
|
|
||||||
Tools: config.ToolsConfig{
|
|
||||||
MCP: config.MCPConfig{
|
|
||||||
ToolConfig: config.ToolConfig{Enabled: true},
|
|
||||||
Servers: map[string]config.MCPServerConfig{
|
|
||||||
"filesystem": {
|
|
||||||
Enabled: false,
|
|
||||||
Type: "stdio",
|
|
||||||
Command: "npx",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
originalProbe := serverProbe
|
|
||||||
defer func() { serverProbe = originalProbe }()
|
|
||||||
serverProbe = func(_ context.Context, name string, _ config.MCPServerConfig, workspacePath string) (probeResult, error) {
|
|
||||||
assert.Equal(t, "filesystem", name)
|
|
||||||
assert.Equal(t, readMCPConfig(t, configPath).WorkspacePath(), workspacePath)
|
|
||||||
return probeResult{ToolCount: 2}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
output, err := executeCommand(cmd, []string{"test", "filesystem"}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, output, `MCP server "filesystem" reachable (2 tools)`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPAddDeferredFlag(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{"add", "--deferred", "myserver", "npx", "my-mcp"}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cfg := readMCPConfig(t, configPath)
|
|
||||||
server := cfg.Tools.MCP.Servers["myserver"]
|
|
||||||
require.NotNil(t, server.Deferred)
|
|
||||||
assert.True(t, *server.Deferred)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPAddNoDeferredFlag(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{"add", "--no-deferred", "myserver", "npx", "my-mcp"}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cfg := readMCPConfig(t, configPath)
|
|
||||||
server := cfg.Tools.MCP.Servers["myserver"]
|
|
||||||
require.NotNil(t, server.Deferred)
|
|
||||||
assert.False(t, *server.Deferred)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPAddNoDeferredByDefault(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{"add", "myserver", "npx", "my-mcp"}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cfg := readMCPConfig(t, configPath)
|
|
||||||
server := cfg.Tools.MCP.Servers["myserver"]
|
|
||||||
assert.Nil(t, server.Deferred)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPShowNotFound(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
writeMCPConfig(t, configPath, nil)
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
_, err := executeCommand(cmd, []string{"show", "missing"}, "")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), `"missing" not found`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPShowDisabledServer(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
writeMCPConfig(t, configPath, &config.Config{
|
|
||||||
Tools: config.ToolsConfig{
|
|
||||||
MCP: config.MCPConfig{
|
|
||||||
ToolConfig: config.ToolConfig{Enabled: true},
|
|
||||||
Servers: map[string]config.MCPServerConfig{
|
|
||||||
"myserver": {
|
|
||||||
Enabled: false,
|
|
||||||
Type: "stdio",
|
|
||||||
Command: "npx",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
output, err := executeCommand(cmd, []string{"show", "myserver"}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, output, "myserver")
|
|
||||||
assert.Contains(t, output, "disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMCPShowUsesProbe(t *testing.T) {
|
|
||||||
configPath := setupMCPConfigEnv(t)
|
|
||||||
writeMCPConfig(t, configPath, &config.Config{
|
|
||||||
Tools: config.ToolsConfig{
|
|
||||||
MCP: config.MCPConfig{
|
|
||||||
ToolConfig: config.ToolConfig{Enabled: true},
|
|
||||||
Servers: map[string]config.MCPServerConfig{
|
|
||||||
"myserver": {
|
|
||||||
Enabled: true,
|
|
||||||
Type: "stdio",
|
|
||||||
Command: "npx",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
original := serverShowProbe
|
|
||||||
defer func() { serverShowProbe = original }()
|
|
||||||
serverShowProbe = func(_ context.Context, name string, _ config.MCPServerConfig, _ string) ([]toolDetail, error) {
|
|
||||||
assert.Equal(t, "myserver", name)
|
|
||||||
return []toolDetail{
|
|
||||||
{
|
|
||||||
Name: "read_file",
|
|
||||||
Description: "Read a file from the filesystem",
|
|
||||||
Parameters: []paramDetail{
|
|
||||||
{Name: "path", Type: "string", Description: "File path", Required: true},
|
|
||||||
{Name: "encoding", Type: "string", Description: "Character encoding", Required: false},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "list_dir",
|
|
||||||
Description: "List directory contents",
|
|
||||||
Parameters: nil,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := NewMCPCommand()
|
|
||||||
output, err := executeCommand(cmd, []string{"show", "myserver"}, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, output, "myserver")
|
|
||||||
assert.Contains(t, output, "read_file")
|
|
||||||
assert.Contains(t, output, "Read a file from the filesystem")
|
|
||||||
assert.Contains(t, output, "path")
|
|
||||||
assert.Contains(t, output, "string")
|
|
||||||
assert.Contains(t, output, "required")
|
|
||||||
assert.Contains(t, output, "list_dir")
|
|
||||||
assert.Contains(t, output, "none")
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupMCPConfigEnv(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
||||||
t.Setenv(config.EnvConfig, configPath)
|
|
||||||
t.Setenv(config.EnvHome, filepath.Dir(configPath))
|
|
||||||
return configPath
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeMCPConfig(t *testing.T, path string, cfg *config.Config) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
if cfg == nil {
|
|
||||||
cfg = config.DefaultConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, config.SaveConfig(path, cfg))
|
|
||||||
}
|
|
||||||
|
|
||||||
func readMCPConfig(t *testing.T, path string) *config.Config {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
cfg, err := config.LoadConfig(path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func executeCommand(cmd *cobra.Command, args []string, stdin string) (string, error) {
|
|
||||||
var stdout bytes.Buffer
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
|
|
||||||
cmd.SetArgs(args)
|
|
||||||
cmd.SetOut(&stdout)
|
|
||||||
cmd.SetErr(&stderr)
|
|
||||||
cmd.SetIn(strings.NewReader(stdin))
|
|
||||||
|
|
||||||
err := cmd.Execute()
|
|
||||||
return stdout.String() + stderr.String(), err
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
package mcp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"go.mau.fi/util/shlex"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newEditCommand() *cobra.Command {
|
|
||||||
return &cobra.Command{
|
|
||||||
Use: "edit",
|
|
||||||
Short: "Open the PicoClaw config in $EDITOR",
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
||||||
editor := strings.TrimSpace(os.Getenv("EDITOR"))
|
|
||||||
if editor == "" {
|
|
||||||
return fmt.Errorf("$EDITOR is not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := loadConfig()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = saveValidatedConfig(cfg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
editorArgs, err := shlex.Split(editor)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to parse $EDITOR: %w", err)
|
|
||||||
}
|
|
||||||
if len(editorArgs) == 0 {
|
|
||||||
return fmt.Errorf("$EDITOR is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
editorArgs = append(editorArgs, internal.GetConfigPath())
|
|
||||||
process := editorCommand(editorArgs[0], editorArgs[1:]...)
|
|
||||||
process.Stdin = cmd.InOrStdin()
|
|
||||||
process.Stdout = cmd.OutOrStdout()
|
|
||||||
process.Stderr = cmd.ErrOrStderr()
|
|
||||||
|
|
||||||
if err := process.Run(); err != nil {
|
|
||||||
return fmt.Errorf("failed to start editor: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,374 +0,0 @@
|
||||||
package mcp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/google/jsonschema-go/jsonschema"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
picomcp "github.com/sipeed/picoclaw/pkg/mcp"
|
|
||||||
)
|
|
||||||
|
|
||||||
type probeResult struct {
|
|
||||||
ToolCount int
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
editorCommand = exec.Command
|
|
||||||
serverProbe = defaultServerProbe
|
|
||||||
|
|
||||||
mcpConfigSchemaOnce sync.Once
|
|
||||||
mcpConfigSchema *jsonschema.Resolved
|
|
||||||
errMcpConfigSchema error
|
|
||||||
)
|
|
||||||
|
|
||||||
const mcpConfigSchemaJSON = `{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"tools": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"mcp": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"enabled": { "type": "boolean" },
|
|
||||||
"discovery": { "type": "object", "additionalProperties": true },
|
|
||||||
"max_inline_text_chars": { "type": "integer" },
|
|
||||||
"servers": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"enabled": { "type": "boolean" },
|
|
||||||
"deferred": { "type": "boolean" },
|
|
||||||
"command": { "type": "string" },
|
|
||||||
"args": {
|
|
||||||
"type": "array",
|
|
||||||
"items": { "type": "string" }
|
|
||||||
},
|
|
||||||
"env": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": { "type": "string" }
|
|
||||||
},
|
|
||||||
"env_file": { "type": "string" },
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["stdio", "http", "sse"]
|
|
||||||
},
|
|
||||||
"url": { "type": "string" },
|
|
||||||
"headers": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": { "type": "string" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["enabled"],
|
|
||||||
"anyOf": [
|
|
||||||
{ "required": ["command"] },
|
|
||||||
{ "required": ["url"] }
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["enabled"],
|
|
||||||
"additionalProperties": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["mcp"],
|
|
||||||
"additionalProperties": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["tools"],
|
|
||||||
"additionalProperties": true
|
|
||||||
}`
|
|
||||||
|
|
||||||
func loadConfig() (*config.Config, error) {
|
|
||||||
cfg, err := config.LoadConfig(internal.GetConfigPath())
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveValidatedConfig(cfg *config.Config) error {
|
|
||||||
if cfg == nil {
|
|
||||||
return fmt.Errorf("config is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizedCfg := normalizedConfigForSave(cfg)
|
|
||||||
|
|
||||||
data, err := json.Marshal(normalizedCfg)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to serialize config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := validateConfigDocument(data); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := config.SaveConfig(internal.GetConfigPath(), normalizedCfg); err != nil {
|
|
||||||
return fmt.Errorf("failed to save config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizedConfigForSave(cfg *config.Config) *config.Config {
|
|
||||||
clone := *cfg
|
|
||||||
if cfg.Tools.MCP.Servers == nil {
|
|
||||||
return &clone
|
|
||||||
}
|
|
||||||
|
|
||||||
clone.Tools = cfg.Tools
|
|
||||||
clone.Tools.MCP = cfg.Tools.MCP
|
|
||||||
clone.Tools.MCP.Servers = make(map[string]config.MCPServerConfig, len(cfg.Tools.MCP.Servers))
|
|
||||||
for name, server := range cfg.Tools.MCP.Servers {
|
|
||||||
if server.Type != "" {
|
|
||||||
server.Type = config.NormalizeMCPTransportType(server.Type)
|
|
||||||
}
|
|
||||||
clone.Tools.MCP.Servers[name] = server
|
|
||||||
}
|
|
||||||
|
|
||||||
return &clone
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateConfigDocument(data []byte) error {
|
|
||||||
var instance map[string]any
|
|
||||||
if err := json.Unmarshal(data, &instance); err != nil {
|
|
||||||
return fmt.Errorf("failed to decode serialized config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
schema, err := loadMCPConfigSchema()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load MCP config schema: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := schema.Validate(instance); err != nil {
|
|
||||||
return fmt.Errorf("config validation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadMCPConfigSchema() (*jsonschema.Resolved, error) {
|
|
||||||
mcpConfigSchemaOnce.Do(func() {
|
|
||||||
var schema jsonschema.Schema
|
|
||||||
if err := json.Unmarshal([]byte(mcpConfigSchemaJSON), &schema); err != nil {
|
|
||||||
errMcpConfigSchema = err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mcpConfigSchema, errMcpConfigSchema = schema.Resolve(nil)
|
|
||||||
})
|
|
||||||
|
|
||||||
return mcpConfigSchema, errMcpConfigSchema
|
|
||||||
}
|
|
||||||
|
|
||||||
func inferTransportType(server config.MCPServerConfig) string {
|
|
||||||
transport := config.EffectiveMCPTransportType(server)
|
|
||||||
if transport == "" {
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
return transport
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderServerTarget(server config.MCPServerConfig) string {
|
|
||||||
transport := inferTransportType(server)
|
|
||||||
if transport == "http" || transport == "sse" {
|
|
||||||
if server.URL == "" {
|
|
||||||
return "<missing url>"
|
|
||||||
}
|
|
||||||
return server.URL
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := append([]string{server.Command}, server.Args...)
|
|
||||||
rendered := strings.TrimSpace(strings.Join(parts, " "))
|
|
||||||
if rendered == "" {
|
|
||||||
return "<missing command>"
|
|
||||||
}
|
|
||||||
return rendered
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedServerNames(servers map[string]config.MCPServerConfig) []string {
|
|
||||||
names := make([]string, 0, len(servers))
|
|
||||||
for name := range servers {
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseEnvAssignments(values []string) (map[string]string, error) {
|
|
||||||
if len(values) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
env := make(map[string]string, len(values))
|
|
||||||
for _, entry := range values {
|
|
||||||
key, value, found := strings.Cut(entry, "=")
|
|
||||||
if !found {
|
|
||||||
return nil, fmt.Errorf("invalid env assignment %q: expected KEY=value", entry)
|
|
||||||
}
|
|
||||||
key = strings.TrimSpace(key)
|
|
||||||
if key == "" {
|
|
||||||
return nil, fmt.Errorf("invalid env assignment %q: key cannot be empty", entry)
|
|
||||||
}
|
|
||||||
env[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
return env, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseHeaderAssignments(values []string) (map[string]string, error) {
|
|
||||||
if len(values) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
headers := make(map[string]string, len(values))
|
|
||||||
for _, entry := range values {
|
|
||||||
key, value, found := strings.Cut(entry, ":")
|
|
||||||
if !found {
|
|
||||||
key, value, found = strings.Cut(entry, "=")
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return nil, fmt.Errorf("invalid header %q: expected 'Name: Value' or 'Name=Value'", entry)
|
|
||||||
}
|
|
||||||
key = strings.TrimSpace(key)
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if key == "" {
|
|
||||||
return nil, fmt.Errorf("invalid header %q: name cannot be empty", entry)
|
|
||||||
}
|
|
||||||
headers[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func looksLikeRemoteURL(target string) bool {
|
|
||||||
parsedURL, err := url.ParseRequestURI(target)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if parsedURL.Host == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
switch strings.ToLower(parsedURL.Scheme) {
|
|
||||||
case "http", "https":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isLocalCommandPath(command string) bool {
|
|
||||||
if command == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if looksLikeRemoteURL(command) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return filepath.IsAbs(command) ||
|
|
||||||
filepath.VolumeName(command) != "" ||
|
|
||||||
strings.HasPrefix(command, "."+string(os.PathSeparator)) ||
|
|
||||||
strings.HasPrefix(command, ".."+string(os.PathSeparator)) ||
|
|
||||||
command == "." ||
|
|
||||||
command == ".." ||
|
|
||||||
strings.ContainsRune(command, os.PathSeparator)
|
|
||||||
}
|
|
||||||
|
|
||||||
func expandHomePath(path string) string {
|
|
||||||
if path == "" || path[0] != '~' {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
if path == "~" {
|
|
||||||
return home
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, "~\\") {
|
|
||||||
return filepath.Join(home, path[2:])
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateLocalCommandPath(command string) error {
|
|
||||||
if !isLocalCommandPath(command) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
path := expandHomePath(command)
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return fmt.Errorf("local command %q does not exist", command)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("failed to stat local command %q: %w", command, err)
|
|
||||||
}
|
|
||||||
if info.IsDir() {
|
|
||||||
return fmt.Errorf("local command %q is a directory", command)
|
|
||||||
}
|
|
||||||
if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 {
|
|
||||||
return fmt.Errorf("local command %q is not executable", command)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultServerProbe(
|
|
||||||
ctx context.Context,
|
|
||||||
name string,
|
|
||||||
server config.MCPServerConfig,
|
|
||||||
workspacePath string,
|
|
||||||
) (probeResult, error) {
|
|
||||||
mgr := picomcp.NewManager()
|
|
||||||
defer func() { _ = mgr.Close() }()
|
|
||||||
|
|
||||||
server.Enabled = true
|
|
||||||
mcpCfg := config.MCPConfig{
|
|
||||||
ToolConfig: config.ToolConfig{Enabled: true},
|
|
||||||
Servers: map[string]config.MCPServerConfig{
|
|
||||||
name: server,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := mgr.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil {
|
|
||||||
return probeResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, ok := mgr.GetServer(name)
|
|
||||||
if !ok {
|
|
||||||
return probeResult{}, fmt.Errorf("server %q did not register a connection", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return probeResult{ToolCount: len(conn.Tools)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func confirmOverwrite(r io.Reader, w io.Writer, name string) (bool, error) {
|
|
||||||
if _, err := fmt.Fprintf(w, "MCP server %q already exists. Overwrite? [y/N]: ", name); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var answer string
|
|
||||||
if _, err := fmt.Fscanln(r, &answer); err != nil {
|
|
||||||
if errors.Is(err, io.EOF) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
|
||||||
return answer == "y" || answer == "yes", nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
package mcp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newListCommand() *cobra.Command {
|
|
||||||
var (
|
|
||||||
includeStatus bool
|
|
||||||
timeout time.Duration
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List configured MCP servers",
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
||||||
cfg, err := loadConfig()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cfg.Tools.MCP.Servers) == 0 {
|
|
||||||
fmt.Fprintln(cmd.OutOrStdout(), "No MCP servers configured.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]cliui.MCPListRow, 0, len(cfg.Tools.MCP.Servers))
|
|
||||||
for _, name := range sortedServerNames(cfg.Tools.MCP.Servers) {
|
|
||||||
server := cfg.Tools.MCP.Servers[name]
|
|
||||||
status := "disabled"
|
|
||||||
if server.Enabled {
|
|
||||||
status = "enabled"
|
|
||||||
}
|
|
||||||
|
|
||||||
if includeStatus && server.Enabled {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
||||||
result, probeErr := serverProbe(ctx, name, server, cfg.WorkspacePath())
|
|
||||||
cancel()
|
|
||||||
if probeErr != nil {
|
|
||||||
status = "error"
|
|
||||||
} else {
|
|
||||||
status = fmt.Sprintf("ok (%d tools)", result.ToolCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
effectiveDeferred := cfg.Tools.MCP.Discovery.Enabled
|
|
||||||
deferredExplicit := server.Deferred != nil
|
|
||||||
if deferredExplicit {
|
|
||||||
effectiveDeferred = *server.Deferred
|
|
||||||
}
|
|
||||||
|
|
||||||
rows = append(rows, cliui.MCPListRow{
|
|
||||||
Name: name,
|
|
||||||
Type: inferTransportType(server),
|
|
||||||
Target: renderServerTarget(server),
|
|
||||||
Status: status,
|
|
||||||
EffectiveDeferred: effectiveDeferred,
|
|
||||||
DeferredExplicit: deferredExplicit,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
cliui.PrintMCPList(cmd.OutOrStdout(), rows)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&includeStatus, "status", false, "Ping enabled servers and show live status")
|
|
||||||
cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Timeout for each live status check")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
package mcp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newRemoveCommand() *cobra.Command {
|
|
||||||
return &cobra.Command{
|
|
||||||
Use: "remove <name>",
|
|
||||||
Short: "Remove an MCP server from config",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
cfg, err := loadConfig()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
name := args[0]
|
|
||||||
if _, exists := cfg.Tools.MCP.Servers[name]; !exists {
|
|
||||||
return fmt.Errorf("MCP server %q not found", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(cfg.Tools.MCP.Servers, name)
|
|
||||||
if len(cfg.Tools.MCP.Servers) == 0 {
|
|
||||||
cfg.Tools.MCP.Servers = nil
|
|
||||||
cfg.Tools.MCP.Enabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := saveValidatedConfig(cfg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q removed.\n", name)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
package mcp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
picomcp "github.com/sipeed/picoclaw/pkg/mcp"
|
|
||||||
)
|
|
||||||
|
|
||||||
type toolDetail struct {
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
Parameters []paramDetail
|
|
||||||
}
|
|
||||||
|
|
||||||
type paramDetail struct {
|
|
||||||
Name string
|
|
||||||
Type string
|
|
||||||
Description string
|
|
||||||
Required bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var serverShowProbe = defaultServerShowProbe
|
|
||||||
|
|
||||||
func defaultServerShowProbe(
|
|
||||||
ctx context.Context,
|
|
||||||
name string,
|
|
||||||
server config.MCPServerConfig,
|
|
||||||
workspacePath string,
|
|
||||||
) ([]toolDetail, error) {
|
|
||||||
mgr := picomcp.NewManager()
|
|
||||||
defer func() { _ = mgr.Close() }()
|
|
||||||
|
|
||||||
server.Enabled = true
|
|
||||||
mcpCfg := config.MCPConfig{
|
|
||||||
ToolConfig: config.ToolConfig{Enabled: true},
|
|
||||||
Servers: map[string]config.MCPServerConfig{
|
|
||||||
name: server,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := mgr.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, ok := mgr.GetServer(name)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("server %q did not register a connection", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
details := make([]toolDetail, 0, len(conn.Tools))
|
|
||||||
for _, tool := range conn.Tools {
|
|
||||||
details = append(details, toolDetail{
|
|
||||||
Name: tool.Name,
|
|
||||||
Description: tool.Description,
|
|
||||||
Parameters: extractParameters(tool.InputSchema),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return details, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractParameters(schema any) []paramDetail {
|
|
||||||
schemaMap := normalizeSchema(schema)
|
|
||||||
properties, ok := schemaMap["properties"].(map[string]any)
|
|
||||||
if !ok || len(properties) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
required := make(map[string]struct{})
|
|
||||||
switch raw := schemaMap["required"].(type) {
|
|
||||||
case []string:
|
|
||||||
for _, name := range raw {
|
|
||||||
required[name] = struct{}{}
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
for _, value := range raw {
|
|
||||||
if name, ok := value.(string); ok {
|
|
||||||
required[name] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
names := make([]string, 0, len(properties))
|
|
||||||
for name := range properties {
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
|
|
||||||
params := make([]paramDetail, 0, len(names))
|
|
||||||
for _, name := range names {
|
|
||||||
param := paramDetail{Name: name}
|
|
||||||
if propMap, ok := properties[name].(map[string]any); ok {
|
|
||||||
if typeName, ok := propMap["type"].(string); ok {
|
|
||||||
param.Type = strings.TrimSpace(typeName)
|
|
||||||
}
|
|
||||||
if desc, ok := propMap["description"].(string); ok {
|
|
||||||
param.Description = strings.TrimSpace(desc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, param.Required = required[name]
|
|
||||||
params = append(params, param)
|
|
||||||
}
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeSchema(schema any) map[string]any {
|
|
||||||
if schema == nil {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
if schemaMap, ok := schema.(map[string]any); ok {
|
|
||||||
return schemaMap
|
|
||||||
}
|
|
||||||
|
|
||||||
var jsonData []byte
|
|
||||||
switch raw := schema.(type) {
|
|
||||||
case json.RawMessage:
|
|
||||||
jsonData = raw
|
|
||||||
case []byte:
|
|
||||||
jsonData = raw
|
|
||||||
default:
|
|
||||||
var err error
|
|
||||||
jsonData, err = json.Marshal(schema)
|
|
||||||
if err != nil {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var result map[string]any
|
|
||||||
if err := json.Unmarshal(jsonData, &result); err != nil {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func newShowCommand() *cobra.Command {
|
|
||||||
var timeout time.Duration
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "show <name>",
|
|
||||||
Short: "Show details and tools for a configured MCP server",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
cfg, err := loadConfig()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
name := args[0]
|
|
||||||
server, exists := cfg.Tools.MCP.Servers[name]
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("MCP server %q not found", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
serverInfo := buildServerInfo(name, server, cfg.Tools.MCP.Discovery.Enabled)
|
|
||||||
|
|
||||||
if !server.Enabled {
|
|
||||||
cliui.PrintMCPShow(cmd.OutOrStdout(), serverInfo, nil, true)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
details, err := serverShowProbe(ctx, name, server, cfg.WorkspacePath())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to connect to MCP server %q: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tools := make([]cliui.MCPShowTool, 0, len(details))
|
|
||||||
for _, d := range details {
|
|
||||||
params := make([]cliui.MCPShowParam, 0, len(d.Parameters))
|
|
||||||
for _, p := range d.Parameters {
|
|
||||||
params = append(params, cliui.MCPShowParam{
|
|
||||||
Name: p.Name,
|
|
||||||
Type: p.Type,
|
|
||||||
Description: p.Description,
|
|
||||||
Required: p.Required,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
tools = append(tools, cliui.MCPShowTool{
|
|
||||||
Name: d.Name,
|
|
||||||
Description: d.Description,
|
|
||||||
Parameters: params,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
cliui.PrintMCPShow(cmd.OutOrStdout(), serverInfo, tools, false)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().DurationVar(&timeout, "timeout", 10*time.Second, "Connection timeout")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildServerInfo(name string, server config.MCPServerConfig, discoveryEnabled bool) cliui.MCPShowServer {
|
|
||||||
effectiveDeferred := discoveryEnabled
|
|
||||||
deferredExplicit := server.Deferred != nil
|
|
||||||
if deferredExplicit {
|
|
||||||
effectiveDeferred = *server.Deferred
|
|
||||||
}
|
|
||||||
info := cliui.MCPShowServer{
|
|
||||||
Name: name,
|
|
||||||
Type: inferTransportType(server),
|
|
||||||
Target: renderServerTarget(server),
|
|
||||||
Enabled: server.Enabled,
|
|
||||||
EffectiveDeferred: effectiveDeferred,
|
|
||||||
DeferredExplicit: deferredExplicit,
|
|
||||||
EnvFile: server.EnvFile,
|
|
||||||
}
|
|
||||||
if len(server.Env) > 0 {
|
|
||||||
keys := make([]string, 0, len(server.Env))
|
|
||||||
for k := range server.Env {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
info.EnvKeys = keys
|
|
||||||
}
|
|
||||||
if len(server.Headers) > 0 {
|
|
||||||
keys := make([]string, 0, len(server.Headers))
|
|
||||||
for k := range server.Headers {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
info.Headers = keys
|
|
||||||
}
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
package mcp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestCommand() *cobra.Command {
|
|
||||||
var timeout time.Duration
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "test <name>",
|
|
||||||
Short: "Test connectivity for a configured MCP server",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
cfg, err := loadConfig()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
name := args[0]
|
|
||||||
server, exists := cfg.Tools.MCP.Servers[name]
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("MCP server %q not found", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
result, err := serverProbe(ctx, name, server, cfg.WorkspacePath())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to reach MCP server %q: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q reachable (%d tools).\n", name, result.ToolCount)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Connection timeout")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultAliasName = "custom-prefer"
|
|
||||||
|
|
||||||
func newAddCommand() *cobra.Command {
|
|
||||||
var (
|
|
||||||
apiBase string
|
|
||||||
apiKey string
|
|
||||||
modelID string
|
|
||||||
alias string
|
|
||||||
modelType string
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "add",
|
|
||||||
Short: "Add a model from an OpenAI-compatible endpoint",
|
|
||||||
Long: `Add a model entry by querying an OpenAI-compatible endpoint exposing
|
|
||||||
GET <api-base>/models, then setting it as the default model.
|
|
||||||
|
|
||||||
If --model is omitted, the available models are listed and you can pick one
|
|
||||||
interactively. If --model is provided, the entry is written without contacting
|
|
||||||
the server.
|
|
||||||
|
|
||||||
Sample interactive session (key shown masked):
|
|
||||||
|
|
||||||
$ picoclaw model add \
|
|
||||||
-b https://ark.cn-beijing.volces.com/api/v3 \
|
|
||||||
-k 7dff****-****-****-****-********e829
|
|
||||||
|
|
||||||
115 model(s) available:
|
|
||||||
1) doubao-lite-128k-240428 (doubao-lite-128k)
|
|
||||||
2) doubao-pro-128k-240515 (doubao-pro-128k)
|
|
||||||
...
|
|
||||||
48) deepseek-r1-250120 (deepseek-r1)
|
|
||||||
78) kimi-k2-250711 (kimi-k2)
|
|
||||||
...
|
|
||||||
115) doubao-seed3d-2-0-260328 (doubao-seed3d-2-0)
|
|
||||||
Pick a model (number or id): 48
|
|
||||||
✓ Saved model 'custom-prefer' (deepseek-r1-250120) and set as default.`,
|
|
||||||
Example: ` picoclaw model add --api-base https://api.openai.com/v1 --api-key sk-...
|
|
||||||
picoclaw model add -b http://localhost:8000/v1 -k dummy -m my-model -n local`,
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
||||||
return runAdd(addOptions{
|
|
||||||
apiBase: strings.TrimSpace(apiBase),
|
|
||||||
apiKey: strings.TrimSpace(apiKey),
|
|
||||||
modelID: strings.TrimSpace(modelID),
|
|
||||||
alias: strings.TrimSpace(alias),
|
|
||||||
modelType: strings.TrimSpace(modelType),
|
|
||||||
stdin: cmd.InOrStdin(),
|
|
||||||
stdout: cmd.OutOrStdout(),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&apiBase, "api-base", "b", "",
|
|
||||||
"API base URL (required), e.g. https://api.openai.com/v1")
|
|
||||||
cmd.Flags().StringVarP(&apiKey, "api-key", "k", "", "API key (required)")
|
|
||||||
cmd.Flags().StringVarP(&modelID, "model", "m", "",
|
|
||||||
"Model id; when set, skips the interactive picker and the network call")
|
|
||||||
cmd.Flags().StringVarP(&alias, "name", "n", defaultAliasName,
|
|
||||||
"Local alias written to model_list and used as the default model name")
|
|
||||||
cmd.Flags().StringVar(&modelType, "type", "openai-compatible",
|
|
||||||
"Endpoint type (only 'openai-compatible' is supported today)")
|
|
||||||
_ = cmd.MarkFlagRequired("api-base")
|
|
||||||
_ = cmd.MarkFlagRequired("api-key")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
type addOptions struct {
|
|
||||||
apiBase string
|
|
||||||
apiKey string
|
|
||||||
modelID string
|
|
||||||
alias string
|
|
||||||
modelType string
|
|
||||||
stdin io.Reader
|
|
||||||
stdout io.Writer
|
|
||||||
}
|
|
||||||
|
|
||||||
func runAdd(opt addOptions) error {
|
|
||||||
if opt.modelType != "" && opt.modelType != "openai-compatible" {
|
|
||||||
return fmt.Errorf("unsupported --type %q (only 'openai-compatible' is supported)", opt.modelType)
|
|
||||||
}
|
|
||||||
if opt.alias == "" {
|
|
||||||
opt.alias = defaultAliasName
|
|
||||||
}
|
|
||||||
|
|
||||||
selected := opt.modelID
|
|
||||||
if selected == "" {
|
|
||||||
entries, err := fetchOpenAIModels(opt.apiBase, opt.apiKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("fetch models: %w", err)
|
|
||||||
}
|
|
||||||
if len(entries) == 0 {
|
|
||||||
return fmt.Errorf("no models returned by %s", opt.apiBase)
|
|
||||||
}
|
|
||||||
selected, err = pickModel(opt.stdin, opt.stdout, entries)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return upsertModelDefault(opt.apiBase, opt.apiKey, opt.alias, selected, opt.stdout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pickModel(stdin io.Reader, stdout io.Writer, entries []modelEntry) (string, error) {
|
|
||||||
fmt.Fprintf(stdout, "\n%d model(s) available:\n", len(entries))
|
|
||||||
for i, m := range entries {
|
|
||||||
line := m.ID
|
|
||||||
if m.Name != "" && m.Name != m.ID {
|
|
||||||
line = fmt.Sprintf("%s (%s)", m.ID, m.Name)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(stdout, " %3d) %s\n", i+1, line)
|
|
||||||
}
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(stdin)
|
|
||||||
for {
|
|
||||||
fmt.Fprint(stdout, "Pick a model (number or id): ")
|
|
||||||
if !scanner.Scan() {
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return "", fmt.Errorf("read input: %w", err)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("no selection provided")
|
|
||||||
}
|
|
||||||
text := strings.TrimSpace(scanner.Text())
|
|
||||||
if text == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if idx, err := strconv.Atoi(text); err == nil {
|
|
||||||
if idx < 1 || idx > len(entries) {
|
|
||||||
fmt.Fprintf(stdout, "Out of range. Enter 1-%d.\n", len(entries))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return entries[idx-1].ID, nil
|
|
||||||
}
|
|
||||||
for _, m := range entries {
|
|
||||||
if m.ID == text {
|
|
||||||
return m.ID, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Fprintln(stdout, "Not a valid number or model id; try again.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func upsertModelDefault(apiBase, apiKey, alias, modelID string, stdout io.Writer) error {
|
|
||||||
configPath := internal.GetConfigPath()
|
|
||||||
cfg, err := config.LoadConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
secureKeys := config.SimpleSecureStrings(apiKey)
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for _, m := range cfg.ModelList {
|
|
||||||
if m == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if m.ModelName == alias {
|
|
||||||
m.Model = modelID
|
|
||||||
m.APIBase = apiBase
|
|
||||||
m.APIKeys = secureKeys
|
|
||||||
m.Enabled = true
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
|
|
||||||
ModelName: alias,
|
|
||||||
Model: modelID,
|
|
||||||
APIBase: apiBase,
|
|
||||||
APIKeys: secureKeys,
|
|
||||||
Enabled: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Agents.Defaults.ModelName = alias
|
|
||||||
|
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
|
||||||
return fmt.Errorf("failed to save config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(stdout, "✓ Saved model '%s' (%s) and set as default.\n", alias, modelID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,257 +0,0 @@
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestFetchOpenAIModels_DataEnvelope(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "/models", r.URL.Path)
|
|
||||||
assert.Equal(t, "Bearer secret", r.Header.Get("Authorization"))
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-foo","name":"Foo"},{"id":"gpt-bar"}]}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
entries, err := fetchOpenAIModels(srv.URL, "secret")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, entries, 2)
|
|
||||||
assert.Equal(t, "gpt-foo", entries[0].ID)
|
|
||||||
assert.Equal(t, "Foo", entries[0].Name)
|
|
||||||
assert.Equal(t, "gpt-bar", entries[1].ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetchOpenAIModels_BareArray(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`[{"id":"a"},{"id":"b"}]`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
entries, err := fetchOpenAIModels(srv.URL, "secret")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, entries, 2)
|
|
||||||
assert.Equal(t, "a", entries[0].ID)
|
|
||||||
assert.Equal(t, "b", entries[1].ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetchOpenAIModels_TrimsTrailingSlash(t *testing.T) {
|
|
||||||
var gotPath string
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
gotPath = r.URL.Path
|
|
||||||
_, _ = w.Write([]byte(`{"data":[{"id":"x"}]}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
_, err := fetchOpenAIModels(srv.URL+"/", "k")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "/models", gotPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetchOpenAIModels_HTTPError(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
http.Error(w, "nope", http.StatusUnauthorized)
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
_, err := fetchOpenAIModels(srv.URL, "bad")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "HTTP 401")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetchOpenAIModels_EmptyDataEnvelope(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_, _ = w.Write([]byte(`{"data":[]}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
entries, err := fetchOpenAIModels(srv.URL, "k")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetchOpenAIModels_EmptyBareArray(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_, _ = w.Write([]byte(`[]`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
entries, err := fetchOpenAIModels(srv.URL, "k")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetchOpenAIModels_UnrecognizedShape(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_, _ = w.Write([]byte(`{"models":"not-supported"}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
_, err := fetchOpenAIModels(srv.URL, "k")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "unrecognized shape")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetchOpenAIModels_RequiresInputs(t *testing.T) {
|
|
||||||
_, err := fetchOpenAIModels("", "k")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "api base")
|
|
||||||
|
|
||||||
_, err = fetchOpenAIModels("https://example.com", "")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "api key")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPickModel_ByIndex(t *testing.T) {
|
|
||||||
entries := []modelEntry{{ID: "a"}, {ID: "b"}, {ID: "c"}}
|
|
||||||
out := &bytes.Buffer{}
|
|
||||||
got, err := pickModel(strings.NewReader("2\n"), out, entries)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "b", got)
|
|
||||||
assert.Contains(t, out.String(), "3 model(s) available")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPickModel_ByID(t *testing.T) {
|
|
||||||
entries := []modelEntry{{ID: "alpha"}, {ID: "beta"}}
|
|
||||||
out := &bytes.Buffer{}
|
|
||||||
got, err := pickModel(strings.NewReader("beta\n"), out, entries)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "beta", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPickModel_RetriesOnInvalid(t *testing.T) {
|
|
||||||
entries := []modelEntry{{ID: "x"}}
|
|
||||||
out := &bytes.Buffer{}
|
|
||||||
got, err := pickModel(strings.NewReader("\n9\nnot-a-model\nx\n"), out, entries)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "x", got)
|
|
||||||
rendered := out.String()
|
|
||||||
assert.Contains(t, rendered, "Out of range")
|
|
||||||
assert.Contains(t, rendered, "Not a valid number")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunAdd_WithExplicitModel_NoNetwork(t *testing.T) {
|
|
||||||
initTest(t)
|
|
||||||
|
|
||||||
out := &bytes.Buffer{}
|
|
||||||
err := runAdd(addOptions{
|
|
||||||
apiBase: "https://invalid.invalid/v1",
|
|
||||||
apiKey: "k",
|
|
||||||
modelID: "explicit-model",
|
|
||||||
alias: "myalias",
|
|
||||||
modelType: "openai-compatible",
|
|
||||||
stdout: out,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, out.String(), "Saved model 'myalias' (explicit-model)")
|
|
||||||
|
|
||||||
cfg, err := config.LoadConfig(configPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "myalias", cfg.Agents.Defaults.GetModelName())
|
|
||||||
added := findModelByName(cfg, "myalias")
|
|
||||||
require.NotNil(t, added, "expected model 'myalias' in model_list")
|
|
||||||
assert.Equal(t, "explicit-model", added.Model)
|
|
||||||
assert.Equal(t, "https://invalid.invalid/v1", added.APIBase)
|
|
||||||
assert.True(t, added.Enabled)
|
|
||||||
require.Len(t, added.APIKeys, 1)
|
|
||||||
assert.Equal(t, "k", added.APIKeys[0].String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func findModelByName(cfg *config.Config, name string) *config.ModelConfig {
|
|
||||||
for _, m := range cfg.ModelList {
|
|
||||||
if m != nil && m.ModelName == name {
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunAdd_FetchAndPick(t *testing.T) {
|
|
||||||
initTest(t)
|
|
||||||
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "Bearer my-key", r.Header.Get("Authorization"))
|
|
||||||
_, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
out := &bytes.Buffer{}
|
|
||||||
err := runAdd(addOptions{
|
|
||||||
apiBase: srv.URL,
|
|
||||||
apiKey: "my-key",
|
|
||||||
alias: defaultAliasName,
|
|
||||||
modelType: "openai-compatible",
|
|
||||||
stdin: strings.NewReader("2\n"),
|
|
||||||
stdout: out,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cfg, err := config.LoadConfig(configPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, defaultAliasName, cfg.Agents.Defaults.GetModelName())
|
|
||||||
added := findModelByName(cfg, defaultAliasName)
|
|
||||||
require.NotNil(t, added)
|
|
||||||
assert.Equal(t, "m2", added.Model)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunAdd_UpsertsExistingAlias(t *testing.T) {
|
|
||||||
initTest(t)
|
|
||||||
|
|
||||||
first := &bytes.Buffer{}
|
|
||||||
require.NoError(t, runAdd(addOptions{
|
|
||||||
apiBase: "https://a.example/v1",
|
|
||||||
apiKey: "k1",
|
|
||||||
modelID: "m1",
|
|
||||||
alias: "shared",
|
|
||||||
stdout: first,
|
|
||||||
}))
|
|
||||||
|
|
||||||
second := &bytes.Buffer{}
|
|
||||||
require.NoError(t, runAdd(addOptions{
|
|
||||||
apiBase: "https://b.example/v1",
|
|
||||||
apiKey: "k2",
|
|
||||||
modelID: "m2",
|
|
||||||
alias: "shared",
|
|
||||||
stdout: second,
|
|
||||||
}))
|
|
||||||
|
|
||||||
cfg, err := config.LoadConfig(configPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
matches := 0
|
|
||||||
for _, m := range cfg.ModelList {
|
|
||||||
if m != nil && m.ModelName == "shared" {
|
|
||||||
matches++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert.Equal(t, 1, matches, "alias should be updated, not duplicated")
|
|
||||||
|
|
||||||
updated := findModelByName(cfg, "shared")
|
|
||||||
require.NotNil(t, updated)
|
|
||||||
assert.Equal(t, "m2", updated.Model)
|
|
||||||
assert.Equal(t, "https://b.example/v1", updated.APIBase)
|
|
||||||
assert.Equal(t, "k2", updated.APIKeys[0].String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunAdd_RejectsUnsupportedType(t *testing.T) {
|
|
||||||
initTest(t)
|
|
||||||
|
|
||||||
err := runAdd(addOptions{
|
|
||||||
apiBase: "https://x/v1",
|
|
||||||
apiKey: "k",
|
|
||||||
modelID: "m",
|
|
||||||
alias: "a",
|
|
||||||
modelType: "anthropic",
|
|
||||||
stdout: &bytes.Buffer{},
|
|
||||||
})
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "unsupported --type")
|
|
||||||
}
|
|
||||||
|
|
@ -21,17 +21,11 @@ func NewModelCommand() *cobra.Command {
|
||||||
If no argument is provided, shows the current default model.
|
If no argument is provided, shows the current default model.
|
||||||
If a model name is provided, sets it as the default model.
|
If a model name is provided, sets it as the default model.
|
||||||
|
|
||||||
To onboard a model from a custom OpenAI-compatible endpoint (fetch the
|
|
||||||
available list online and pick one), use the 'add' subcommand:
|
|
||||||
|
|
||||||
picoclaw model add --help
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
picoclaw model # Show current default model
|
picoclaw model # Show current default model
|
||||||
picoclaw model gpt-5.2 # Set gpt-5.2 as default
|
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 claude-sonnet-4.6 # Set claude-sonnet-4.6 as default
|
||||||
picoclaw model local-model # Set local VLLM server as default
|
picoclaw model local-model # Set local VLLM server as default
|
||||||
picoclaw model add -b URL -k KEY # Add a model from a custom endpoint
|
|
||||||
|
|
||||||
Note: 'local-model' is a special value for using a local VLLM server
|
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.`,
|
(running at localhost:8000 by default) which does not require an API key.`,
|
||||||
|
|
@ -57,8 +51,6 @@ Note: 'local-model' is a special value for using a local VLLM server
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(newAddCommand())
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,9 +66,6 @@ func showCurrentModel(cfg *config.Config) {
|
||||||
fmt.Println("\nAvailable models in your config:")
|
fmt.Println("\nAvailable models in your config:")
|
||||||
listAvailableModels(cfg)
|
listAvailableModels(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("\nTip: 'picoclaw model add -b URL -k KEY' adds a model from a custom")
|
|
||||||
fmt.Println(" OpenAI-compatible endpoint (see 'picoclaw model add --help').")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func listAvailableModels(cfg *config.Config) {
|
func listAvailableModels(cfg *config.Config) {
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type modelEntry struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type modelsAPIResponse struct {
|
|
||||||
Data []modelEntry `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchOpenAIModels GETs <baseURL>/models with Bearer auth and accepts both the
|
|
||||||
// {data:[…]} envelope and a bare array shape used by various OpenAI-compatible servers.
|
|
||||||
func fetchOpenAIModels(baseURL, apiKey string) ([]modelEntry, error) {
|
|
||||||
if strings.TrimSpace(baseURL) == "" {
|
|
||||||
return nil, fmt.Errorf("api base is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(apiKey) == "" {
|
|
||||||
return nil, fmt.Errorf("api key is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
url := strings.TrimRight(baseURL, "/") + "/models"
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 15 * time.Second}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("build request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
||||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// {"data": [...]} envelope. Distinguish "envelope shape with empty list"
|
|
||||||
// from "object without a data key" via Data being non-nil after unmarshal:
|
|
||||||
// json.Unmarshal sets Data to []modelEntry{} for `{"data":[]}` but leaves
|
|
||||||
// it as nil when "data" is absent or null.
|
|
||||||
var envelope modelsAPIResponse
|
|
||||||
if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil {
|
|
||||||
return envelope.Data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bare-array shape, including `[]`.
|
|
||||||
var arr []modelEntry
|
|
||||||
if err := json.Unmarshal(body, &arr); err == nil {
|
|
||||||
return arr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
preview := body
|
|
||||||
if len(preview) > 256 {
|
|
||||||
preview = preview[:256]
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("decode response: unrecognized shape: %s", strings.TrimSpace(string(preview)))
|
|
||||||
}
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:generate go run ../../../../scripts/copydir.go ../../../../workspace ./workspace
|
//go:generate cp -r ../../../../workspace .
|
||||||
//go:embed workspace
|
//go:embed workspace
|
||||||
var embeddedFiles embed.FS
|
var embeddedFiles embed.FS
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import (
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/credential"
|
"github.com/sipeed/picoclaw/pkg/credential"
|
||||||
)
|
)
|
||||||
|
|
@ -80,7 +79,29 @@ func onboard(encrypt bool) {
|
||||||
workspace := cfg.WorkspacePath()
|
workspace := cfg.WorkspacePath()
|
||||||
createWorkspaceTemplates(workspace)
|
createWorkspaceTemplates(workspace)
|
||||||
|
|
||||||
cliui.PrintOnboardComplete(internal.Logo, encrypt, configPath)
|
fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo)
|
||||||
|
fmt.Println("\nNext steps:")
|
||||||
|
if encrypt {
|
||||||
|
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
|
||||||
|
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
|
||||||
|
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println(" 2. Add your API key to", configPath)
|
||||||
|
} else {
|
||||||
|
fmt.Println(" 1. Add your API key to", configPath)
|
||||||
|
}
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println(" Recommended:")
|
||||||
|
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
|
||||||
|
fmt.Println(" - Ollama: https://ollama.com (local, free)")
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println(" See README.md for 17+ supported providers.")
|
||||||
|
fmt.Println("")
|
||||||
|
if encrypt {
|
||||||
|
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
|
||||||
|
} else {
|
||||||
|
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// promptPassphrase reads the encryption passphrase twice from the terminal
|
// promptPassphrase reads the encryption passphrase twice from the terminal
|
||||||
|
|
@ -172,9 +193,6 @@ func copyEmbeddedToTarget(targetDir string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
|
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
|
||||||
}
|
}
|
||||||
if new_path == "AGENTS.md" || new_path == "IDENTITY.md" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build target file path
|
// Build target file path
|
||||||
targetPath := filepath.Join(targetDir, new_path)
|
targetPath := filepath.Join(targetDir, new_path)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
|
|
||||||
type deps struct {
|
type deps struct {
|
||||||
workspace string
|
workspace string
|
||||||
|
installer *skills.SkillInstaller
|
||||||
skillsLoader *skills.SkillsLoader
|
skillsLoader *skills.SkillsLoader
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,6 +29,15 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
d.workspace = cfg.WorkspacePath()
|
d.workspace = cfg.WorkspacePath()
|
||||||
|
installer, err := skills.NewSkillInstaller(
|
||||||
|
d.workspace,
|
||||||
|
cfg.Tools.Skills.Github.Token.String(),
|
||||||
|
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())
|
||||||
|
|
@ -42,6 +52,13 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
installerFn := func() (*skills.SkillInstaller, error) {
|
||||||
|
if d.installer == nil {
|
||||||
|
return nil, fmt.Errorf("skills installer is not initialized")
|
||||||
|
}
|
||||||
|
return d.installer, nil
|
||||||
|
}
|
||||||
|
|
||||||
loaderFn := func() (*skills.SkillsLoader, error) {
|
loaderFn := func() (*skills.SkillsLoader, error) {
|
||||||
if d.skillsLoader == nil {
|
if d.skillsLoader == nil {
|
||||||
return nil, fmt.Errorf("skills loader is not initialized")
|
return nil, fmt.Errorf("skills loader is not initialized")
|
||||||
|
|
@ -58,10 +75,10 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
newListCommand(loaderFn),
|
newListCommand(loaderFn),
|
||||||
newInstallCommand(),
|
newInstallCommand(installerFn),
|
||||||
newInstallBuiltinCommand(workspaceFn),
|
newInstallBuiltinCommand(workspaceFn),
|
||||||
newListBuiltinCommand(),
|
newListBuiltinCommand(),
|
||||||
newRemoveCommand(),
|
newRemoveCommand(installerFn),
|
||||||
newSearchCommand(),
|
newSearchCommand(),
|
||||||
newShowCommand(loaderFn),
|
newShowCommand(loaderFn),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package skills
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -12,23 +11,12 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const skillsSearchMaxResults = 20
|
const skillsSearchMaxResults = 20
|
||||||
|
|
||||||
type installedSkillOriginMeta struct {
|
|
||||||
Version int `json:"version"`
|
|
||||||
OriginKind string `json:"origin_kind,omitempty"`
|
|
||||||
Registry string `json:"registry,omitempty"`
|
|
||||||
Slug string `json:"slug,omitempty"`
|
|
||||||
RegistryURL string `json:"registry_url,omitempty"`
|
|
||||||
InstalledVersion string `json:"installed_version,omitempty"`
|
|
||||||
InstalledAt int64 `json:"installed_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func skillsListCmd(loader *skills.SkillsLoader) {
|
func skillsListCmd(loader *skills.SkillsLoader) {
|
||||||
allSkills := loader.ListSkills()
|
allSkills := loader.ListSkills()
|
||||||
|
|
||||||
|
|
@ -47,32 +35,61 @@ func skillsListCmd(loader *skills.SkillsLoader) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
|
||||||
|
fmt.Printf("Installing skill from %s...\n", repo)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
|
||||||
|
return fmt.Errorf("failed to install skill: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
||||||
func skillsInstallFromRegistry(cfg *config.Config, registryName, target string) error {
|
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {
|
||||||
err := utils.ValidateSkillIdentifier(registryName)
|
err := utils.ValidateSkillIdentifier(registryName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("✗ invalid registry name: %w", err)
|
return fmt.Errorf("✗ invalid registry name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills)
|
err = utils.ValidateSkillIdentifier(slug)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("✗ invalid slug: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
||||||
|
|
||||||
|
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
||||||
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||||
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
|
ClawHub: skills.ClawHubConfig{
|
||||||
|
Enabled: clawHubConfig.Enabled,
|
||||||
|
BaseURL: clawHubConfig.BaseURL,
|
||||||
|
AuthToken: clawHubConfig.AuthToken.String(),
|
||||||
|
SearchPath: clawHubConfig.SearchPath,
|
||||||
|
SkillsPath: clawHubConfig.SkillsPath,
|
||||||
|
DownloadPath: clawHubConfig.DownloadPath,
|
||||||
|
Timeout: clawHubConfig.Timeout,
|
||||||
|
MaxZipSize: clawHubConfig.MaxZipSize,
|
||||||
|
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
registry := registryMgr.GetRegistry(registryName)
|
registry := registryMgr.GetRegistry(registryName)
|
||||||
if registry == nil {
|
if registry == nil {
|
||||||
return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
|
return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
|
||||||
}
|
}
|
||||||
|
|
||||||
dirName, err := registry.ResolveInstallDirName(target)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("✗ invalid install target %q: %w", target, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Installing skill '%s' from %s registry...\n", target, registryName)
|
|
||||||
|
|
||||||
workspace := cfg.WorkspacePath()
|
workspace := cfg.WorkspacePath()
|
||||||
targetDir := filepath.Join(workspace, "skills", dirName)
|
targetDir := filepath.Join(workspace, "skills", slug)
|
||||||
|
|
||||||
if _, err = os.Stat(targetDir); err == nil {
|
if _, err = os.Stat(targetDir); err == nil {
|
||||||
return fmt.Errorf("\u2717 skill '%s' already installed at %s", dirName, targetDir)
|
return fmt.Errorf("\u2717 skill '%s' already installed at %s", slug, targetDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
|
@ -82,7 +99,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, target string)
|
||||||
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
|
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := registry.DownloadAndInstall(ctx, target, "", targetDir)
|
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rmErr := os.RemoveAll(targetDir)
|
rmErr := os.RemoveAll(targetDir)
|
||||||
if rmErr != nil {
|
if rmErr != nil {
|
||||||
|
|
@ -97,34 +114,14 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, target string)
|
||||||
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", target)
|
return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.IsSuspicious {
|
if result.IsSuspicious {
|
||||||
fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", target)
|
fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !workspaceHasValidSkillDirectory(workspace, dirName) {
|
fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", slug, result.Version)
|
||||||
_ = os.RemoveAll(targetDir)
|
|
||||||
return fmt.Errorf("✗ failed to install skill: registry archive for %q is not a valid skill", target)
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizedSlug, registryURL := skills.BuildInstallMetadataForRegistryInstance(registry, target, result.Version)
|
|
||||||
installedAt := time.Now().UnixMilli()
|
|
||||||
if err := writeInstalledSkillOriginMeta(targetDir, installedSkillOriginMeta{
|
|
||||||
Version: 1,
|
|
||||||
OriginKind: "third_party",
|
|
||||||
Registry: registry.Name(),
|
|
||||||
Slug: normalizedSlug,
|
|
||||||
RegistryURL: registryURL,
|
|
||||||
InstalledVersion: result.Version,
|
|
||||||
InstalledAt: installedAt,
|
|
||||||
}); err != nil {
|
|
||||||
_ = os.RemoveAll(targetDir)
|
|
||||||
return fmt.Errorf("✗ failed to persist skill metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", dirName, result.Version)
|
|
||||||
if result.Summary != "" {
|
if result.Summary != "" {
|
||||||
fmt.Printf(" %s\n", result.Summary)
|
fmt.Printf(" %s\n", result.Summary)
|
||||||
}
|
}
|
||||||
|
|
@ -132,51 +129,15 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, target string)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeInstalledSkillOriginMeta(targetDir string, meta installedSkillOriginMeta) error {
|
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
||||||
data, err := json.MarshalIndent(meta, "", " ")
|
fmt.Printf("Removing skill '%s'...\n", skillName)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
|
|
||||||
}
|
|
||||||
|
|
||||||
func workspaceHasValidSkillDirectory(workspace, directory string) bool {
|
if err := installer.Uninstall(skillName); err != nil {
|
||||||
loader := skills.NewSkillsLoader(workspace, "", "")
|
fmt.Printf("✗ Failed to remove skill: %v\n", err)
|
||||||
for _, skill := range loader.ListSkills() {
|
os.Exit(1)
|
||||||
if skill.Source != "workspace" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if filepath.Base(filepath.Dir(skill.Path)) == directory {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func skillsRemoveFromWorkspace(workspace string, toolsConfig config.SkillsToolsConfig, skillName string) error {
|
fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
|
||||||
name := strings.TrimSpace(skillName)
|
|
||||||
name = strings.Trim(name, "/")
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("skill name is required")
|
|
||||||
}
|
|
||||||
if strings.Contains(name, "/") {
|
|
||||||
dirName, err := skills.GitHubInstallDirNameFromToolsConfig(toolsConfig, name)
|
|
||||||
if err != nil || dirName == "" {
|
|
||||||
return fmt.Errorf("invalid skill name %q", skillName)
|
|
||||||
}
|
|
||||||
name = dirName
|
|
||||||
}
|
|
||||||
if name == "." || name == ".." {
|
|
||||||
return fmt.Errorf("invalid skill name %q", skillName)
|
|
||||||
}
|
|
||||||
skillDir := filepath.Join(workspace, "skills", name)
|
|
||||||
if _, err := os.Stat(skillDir); os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("skill '%s' not found", name)
|
|
||||||
}
|
|
||||||
if err := os.RemoveAll(skillDir); err != nil {
|
|
||||||
return fmt.Errorf("failed to remove skill '%s': %w", name, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsInstallBuiltinCmd(workspace string) {
|
func skillsInstallBuiltinCmd(workspace string) {
|
||||||
|
|
@ -276,7 +237,21 @@ func skillsSearchCmd(query string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills)
|
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
||||||
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||||
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
|
ClawHub: skills.ClawHubConfig{
|
||||||
|
Enabled: clawHubConfig.Enabled,
|
||||||
|
BaseURL: clawHubConfig.BaseURL,
|
||||||
|
AuthToken: clawHubConfig.AuthToken.String(),
|
||||||
|
SearchPath: clawHubConfig.SearchPath,
|
||||||
|
SkillsPath: clawHubConfig.SkillsPath,
|
||||||
|
DownloadPath: clawHubConfig.DownloadPath,
|
||||||
|
Timeout: clawHubConfig.Timeout,
|
||||||
|
MaxZipSize: clawHubConfig.MaxZipSize,
|
||||||
|
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
package skills
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSkillsInstallFromRegistryWritesOriginMetadata(t *testing.T) {
|
|
||||||
workspace := t.TempDir()
|
|
||||||
cfg := config.DefaultConfig()
|
|
||||||
cfg.Agents.Defaults.Workspace = workspace
|
|
||||||
|
|
||||||
var server *httptest.Server
|
|
||||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/api/v3/repos/foo/bar":
|
|
||||||
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"}))
|
|
||||||
case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
|
|
||||||
assert.Equal(t, "ref=master", r.URL.RawQuery)
|
|
||||||
require.NoError(t, json.NewEncoder(w).Encode([]map[string]any{{
|
|
||||||
"type": "file",
|
|
||||||
"name": "SKILL.md",
|
|
||||||
"download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
|
|
||||||
}}))
|
|
||||||
case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
|
|
||||||
_, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n"))
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
|
|
||||||
require.True(t, ok)
|
|
||||||
githubRegistry.BaseURL = server.URL
|
|
||||||
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
|
|
||||||
|
|
||||||
target := server.URL + "/foo/bar/tree/master/.agents/skills/pr-review"
|
|
||||||
require.NoError(t, skillsInstallFromRegistry(cfg, "github", target))
|
|
||||||
|
|
||||||
metaPath := filepath.Join(workspace, "skills", "pr-review", ".skill-origin.json")
|
|
||||||
data, err := os.ReadFile(metaPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var meta installedSkillOriginMeta
|
|
||||||
require.NoError(t, json.Unmarshal(data, &meta))
|
|
||||||
assert.Equal(t, "third_party", meta.OriginKind)
|
|
||||||
assert.Equal(t, "github", meta.Registry)
|
|
||||||
assert.Equal(t, "foo/bar/.agents/skills/pr-review", meta.Slug)
|
|
||||||
assert.Equal(t, server.URL+"/foo/bar/tree/master/.agents/skills/pr-review", meta.RegistryURL)
|
|
||||||
assert.Equal(t, "master", meta.InstalledVersion)
|
|
||||||
assert.NotZero(t, meta.InstalledAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSkillsInstallFromRegistryRejectsInvalidSkillArchive(t *testing.T) {
|
|
||||||
workspace := t.TempDir()
|
|
||||||
cfg := config.DefaultConfig()
|
|
||||||
cfg.Agents.Defaults.Workspace = workspace
|
|
||||||
|
|
||||||
var server *httptest.Server
|
|
||||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/api/v3/repos/foo/bar":
|
|
||||||
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"}))
|
|
||||||
case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
|
|
||||||
require.NoError(t, json.NewEncoder(w).Encode([]map[string]any{{
|
|
||||||
"type": "file",
|
|
||||||
"name": "SKILL.md",
|
|
||||||
"download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
|
|
||||||
}}))
|
|
||||||
case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
|
|
||||||
_, _ = w.Write([]byte("---\nname: bad_skill\ndescription: Invalid skill name\n---\n# Invalid\n"))
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
|
|
||||||
require.True(t, ok)
|
|
||||||
githubRegistry.BaseURL = server.URL
|
|
||||||
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
|
|
||||||
|
|
||||||
target := server.URL + "/foo/bar/tree/master/.agents/skills/pr-review"
|
|
||||||
err := skillsInstallFromRegistry(cfg, "github", target)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "is not a valid skill")
|
|
||||||
_, statErr := os.Stat(filepath.Join(workspace, "skills", "pr-review"))
|
|
||||||
assert.True(t, os.IsNotExist(statErr))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSkillsRemoveFromWorkspaceRejectsDotTarget(t *testing.T) {
|
|
||||||
workspace := t.TempDir()
|
|
||||||
skillsDir := filepath.Join(workspace, "skills")
|
|
||||||
require.NoError(t, os.MkdirAll(skillsDir, 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(skillsDir, "keep.txt"), []byte("keep"), 0o644))
|
|
||||||
|
|
||||||
err := skillsRemoveFromWorkspace(workspace, config.DefaultConfig().Tools.Skills, ".")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "invalid skill name")
|
|
||||||
|
|
||||||
_, statErr := os.Stat(skillsDir)
|
|
||||||
assert.NoError(t, statErr)
|
|
||||||
_, fileErr := os.Stat(filepath.Join(skillsDir, "keep.txt"))
|
|
||||||
assert.NoError(t, fileErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSkillsRemoveFromWorkspaceUsesLastPathSegment(t *testing.T) {
|
|
||||||
workspace := t.TempDir()
|
|
||||||
targetDir := filepath.Join(workspace, "skills", "pr-review")
|
|
||||||
require.NoError(t, os.MkdirAll(targetDir, 0o755))
|
|
||||||
|
|
||||||
err := skillsRemoveFromWorkspace(
|
|
||||||
workspace,
|
|
||||||
config.DefaultConfig().Tools.Skills,
|
|
||||||
"https://github.com/foo/bar/tree/main/.agents/skills/pr-review",
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, statErr := os.Stat(targetDir)
|
|
||||||
assert.True(t, os.IsNotExist(statErr))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSkillsRemoveFromWorkspaceSupportsRepoRootGitHubBlobURL(t *testing.T) {
|
|
||||||
workspace := t.TempDir()
|
|
||||||
targetDir := filepath.Join(workspace, "skills", "bar")
|
|
||||||
require.NoError(t, os.MkdirAll(targetDir, 0o755))
|
|
||||||
|
|
||||||
err := skillsRemoveFromWorkspace(
|
|
||||||
workspace,
|
|
||||||
config.DefaultConfig().Tools.Skills,
|
|
||||||
"https://github.com/foo/bar/blob/feature/skills-registry/SKILL.md",
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, statErr := os.Stat(targetDir)
|
|
||||||
assert.True(t, os.IsNotExist(statErr))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSkillsRemoveFromWorkspaceSupportsGitHubEnterpriseURL(t *testing.T) {
|
|
||||||
workspace := t.TempDir()
|
|
||||||
targetDir := filepath.Join(workspace, "skills", "pr-review")
|
|
||||||
require.NoError(t, os.MkdirAll(targetDir, 0o755))
|
|
||||||
|
|
||||||
cfg := config.DefaultConfig()
|
|
||||||
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
|
|
||||||
require.True(t, ok)
|
|
||||||
githubRegistry.BaseURL = "https://ghe.example.com/git"
|
|
||||||
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
|
|
||||||
|
|
||||||
err := skillsRemoveFromWorkspace(
|
|
||||||
workspace,
|
|
||||||
cfg.Tools.Skills,
|
|
||||||
"https://ghe.example.com/git/foo/bar/tree/main/.agents/skills/pr-review",
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, statErr := os.Stat(targetDir)
|
|
||||||
assert.True(t, os.IsNotExist(statErr))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSkillsRemoveFromWorkspaceDoesNotRequireEnabledGitHubRegistry(t *testing.T) {
|
|
||||||
workspace := t.TempDir()
|
|
||||||
targetDir := filepath.Join(workspace, "skills", "pr-review")
|
|
||||||
require.NoError(t, os.MkdirAll(targetDir, 0o755))
|
|
||||||
|
|
||||||
cfg := config.DefaultConfig()
|
|
||||||
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
|
|
||||||
require.True(t, ok)
|
|
||||||
githubRegistry.Enabled = false
|
|
||||||
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
|
|
||||||
|
|
||||||
err := skillsRemoveFromWorkspace(
|
|
||||||
workspace,
|
|
||||||
cfg.Tools.Skills,
|
|
||||||
"https://github.com/foo/bar/tree/main/.agents/skills/pr-review",
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, statErr := os.Stat(targetDir)
|
|
||||||
assert.True(t, os.IsNotExist(statErr))
|
|
||||||
}
|
|
||||||
|
|
@ -6,14 +6,15 @@ 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/skills"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newInstallCommand() *cobra.Command {
|
func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
||||||
var registry string
|
var registry string
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "install",
|
Use: "install",
|
||||||
Short: "Install skill from GitHub or a registry",
|
Short: "Install skill from GitHub",
|
||||||
Example: `
|
Example: `
|
||||||
picoclaw skills install sipeed/picoclaw-skills/weather
|
picoclaw skills install sipeed/picoclaw-skills/weather
|
||||||
picoclaw skills install --registry clawhub github
|
picoclaw skills install --registry clawhub github
|
||||||
|
|
@ -33,15 +34,21 @@ picoclaw skills install --registry clawhub github
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
RunE: func(_ *cobra.Command, args []string) error {
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
cfg, err := internal.LoadConfig()
|
installer, err := installerFn()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if registry != "" {
|
if registry != "" {
|
||||||
|
cfg, err := internal.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return skillsInstallFromRegistry(cfg, registry, args[0])
|
return skillsInstallFromRegistry(cfg, registry, args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
return skillsInstallFromRegistry(cfg, "github", args[0])
|
return skillsInstallCmd(installer, args[0])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewInstallSubcommand(t *testing.T) {
|
func TestNewInstallSubcommand(t *testing.T) {
|
||||||
cmd := newInstallCommand()
|
cmd := newInstallCommand(nil)
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
assert.Equal(t, "install", cmd.Use)
|
assert.Equal(t, "install", cmd.Use)
|
||||||
assert.Equal(t, "Install skill from GitHub or a registry", cmd.Short)
|
assert.Equal(t, "Install skill from GitHub", cmd.Short)
|
||||||
|
|
||||||
assert.Nil(t, cmd.Run)
|
assert.Nil(t, cmd.Run)
|
||||||
assert.NotNil(t, cmd.RunE)
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
@ -79,7 +79,7 @@ func TestInstallCommandArgs(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
cmd := newInstallCommand()
|
cmd := newInstallCommand(nil)
|
||||||
|
|
||||||
if tt.registry != "" {
|
if tt.registry != "" {
|
||||||
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ package skills
|
||||||
import (
|
import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newRemoveCommand() *cobra.Command {
|
func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "remove",
|
Use: "remove",
|
||||||
Aliases: []string{"rm", "uninstall"},
|
Aliases: []string{"rm", "uninstall"},
|
||||||
|
|
@ -14,11 +14,12 @@ func newRemoveCommand() *cobra.Command {
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
Example: `picoclaw skills remove weather`,
|
Example: `picoclaw skills remove weather`,
|
||||||
RunE: func(_ *cobra.Command, args []string) error {
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
cfg, err := internal.LoadConfig()
|
installer, err := installerFn()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return skillsRemoveFromWorkspace(cfg.WorkspacePath(), cfg.Tools.Skills, args[0])
|
skillsRemoveCmd(installer, args[0])
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewRemoveSubcommand(t *testing.T) {
|
func TestNewRemoveSubcommand(t *testing.T) {
|
||||||
cmd := newRemoveCommand()
|
cmd := newRemoveCommand(nil)
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func statusCmd() {
|
func statusCmd() {
|
||||||
|
|
@ -19,127 +17,43 @@ func statusCmd() {
|
||||||
}
|
}
|
||||||
|
|
||||||
configPath := internal.GetConfigPath()
|
configPath := internal.GetConfigPath()
|
||||||
|
|
||||||
|
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
||||||
|
fmt.Printf("Version: %s\n", config.FormatVersion())
|
||||||
build, _ := config.FormatBuildInfo()
|
build, _ := config.FormatBuildInfo()
|
||||||
|
if build != "" {
|
||||||
|
fmt.Printf("Build: %s\n", build)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
_, configStatErr := os.Stat(configPath)
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
configOK := configStatErr == nil
|
fmt.Println("Config:", configPath, "✓")
|
||||||
|
} else {
|
||||||
workspace := cfg.WorkspacePath()
|
fmt.Println("Config:", configPath, "✗")
|
||||||
_, wsErr := os.Stat(workspace)
|
|
||||||
wsOK := wsErr == nil
|
|
||||||
|
|
||||||
report := cliui.StatusReport{
|
|
||||||
Logo: internal.Logo,
|
|
||||||
Version: config.FormatVersion(),
|
|
||||||
Build: build,
|
|
||||||
ConfigPath: configPath,
|
|
||||||
ConfigOK: configOK,
|
|
||||||
WorkspacePath: workspace,
|
|
||||||
WorkspaceOK: wsOK,
|
|
||||||
Model: cfg.Agents.Defaults.GetModelName(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if configOK {
|
workspace := cfg.WorkspacePath()
|
||||||
// PicoClaw moved to a model-centric configuration (model_list). Status should
|
if _, err := os.Stat(workspace); err == nil {
|
||||||
// not depend on a legacy cfg.Providers field (which may not exist under some
|
fmt.Println("Workspace:", workspace, "✓")
|
||||||
// build tags). We infer provider availability from model_list entries.
|
} else {
|
||||||
hasProtocolKey := func(protocol string) bool {
|
fmt.Println("Workspace:", workspace, "✗")
|
||||||
want := providers.NormalizeProvider(protocol)
|
}
|
||||||
for _, m := range cfg.ModelList {
|
|
||||||
if m == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
got, _ := providers.ExtractProtocol(m)
|
|
||||||
if got == want && m.APIKey() != "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
findLocalModelBase := func(modelName string) (string, bool) {
|
|
||||||
for _, m := range cfg.ModelList {
|
|
||||||
if m == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if m.ModelName == modelName && m.APIBase != "" {
|
|
||||||
return m.APIBase, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
findProtocolBase := func(protocol string) (string, bool) {
|
|
||||||
want := providers.NormalizeProvider(protocol)
|
|
||||||
for _, m := range cfg.ModelList {
|
|
||||||
if m == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
got, _ := providers.ExtractProtocol(m)
|
|
||||||
if got == want && m.APIBase != "" {
|
|
||||||
return m.APIBase, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
hasOpenRouter := hasProtocolKey("openrouter")
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
hasAnthropic := hasProtocolKey("anthropic")
|
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
|
||||||
hasOpenAI := hasProtocolKey("openai")
|
|
||||||
hasGemini := hasProtocolKey("gemini")
|
|
||||||
hasZhipu := hasProtocolKey("zhipu")
|
|
||||||
hasQwen := hasProtocolKey("qwen")
|
|
||||||
hasGroq := hasProtocolKey("groq")
|
|
||||||
hasMoonshot := hasProtocolKey("moonshot")
|
|
||||||
hasDeepSeek := hasProtocolKey("deepseek")
|
|
||||||
hasVolcEngine := hasProtocolKey("volcengine")
|
|
||||||
hasNvidia := hasProtocolKey("nvidia")
|
|
||||||
|
|
||||||
// Local endpoints: allow both the special reserved name and protocol-based entries.
|
|
||||||
vllmBase, hasVLLM := findLocalModelBase("local-model")
|
|
||||||
if !hasVLLM {
|
|
||||||
vllmBase, hasVLLM = findProtocolBase("vllm")
|
|
||||||
}
|
|
||||||
ollamaBase, hasOllama := findProtocolBase("ollama")
|
|
||||||
|
|
||||||
val := func(enabled bool, extra ...string) string {
|
|
||||||
if enabled {
|
|
||||||
if len(extra) > 0 && extra[0] != "" {
|
|
||||||
return "✓ " + extra[0]
|
|
||||||
}
|
|
||||||
return "✓"
|
|
||||||
}
|
|
||||||
return "not set"
|
|
||||||
}
|
|
||||||
|
|
||||||
report.Providers = []cliui.ProviderRow{
|
|
||||||
{Name: "OpenRouter API", Val: val(hasOpenRouter)},
|
|
||||||
{Name: "Anthropic API", Val: val(hasAnthropic)},
|
|
||||||
{Name: "OpenAI API", Val: val(hasOpenAI)},
|
|
||||||
{Name: "Gemini API", Val: val(hasGemini)},
|
|
||||||
{Name: "Zhipu API", Val: val(hasZhipu)},
|
|
||||||
{Name: "Qwen API", Val: val(hasQwen)},
|
|
||||||
{Name: "Groq API", Val: val(hasGroq)},
|
|
||||||
{Name: "Moonshot API", Val: val(hasMoonshot)},
|
|
||||||
{Name: "DeepSeek API", Val: val(hasDeepSeek)},
|
|
||||||
{Name: "VolcEngine API", Val: val(hasVolcEngine)},
|
|
||||||
{Name: "Nvidia API", Val: val(hasNvidia)},
|
|
||||||
{Name: "vLLM / local", Val: val(hasVLLM, vllmBase)},
|
|
||||||
{Name: "Ollama", Val: val(hasOllama, ollamaBase)},
|
|
||||||
}
|
|
||||||
|
|
||||||
store, _ := auth.LoadStore()
|
store, _ := auth.LoadStore()
|
||||||
if store != nil && len(store.Credentials) > 0 {
|
if store != nil && len(store.Credentials) > 0 {
|
||||||
|
fmt.Println("\nOAuth/Token Auth:")
|
||||||
for provider, cred := range store.Credentials {
|
for provider, cred := range store.Credentials {
|
||||||
st := "authenticated"
|
status := "authenticated"
|
||||||
if cred.IsExpired() {
|
if cred.IsExpired() {
|
||||||
st = "expired"
|
status = "expired"
|
||||||
} else if cred.NeedsRefresh() {
|
} else if cred.NeedsRefresh() {
|
||||||
st = "needs refresh"
|
status = "needs refresh"
|
||||||
}
|
}
|
||||||
report.OAuthLines = append(report.OAuthLines,
|
fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
|
||||||
fmt.Sprintf("%s (%s): %s", provider, cred.AuthMethod, st))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cliui.PrintStatus(report)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
package status
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func captureStdout(t *testing.T, fn func()) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
oldStdout := os.Stdout
|
|
||||||
r, w, err := os.Pipe()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("os.Pipe() error = %v", err)
|
|
||||||
}
|
|
||||||
os.Stdout = w
|
|
||||||
|
|
||||||
fn()
|
|
||||||
|
|
||||||
_ = w.Close()
|
|
||||||
os.Stdout = oldStdout
|
|
||||||
defer r.Close()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if _, err := io.Copy(&buf, r); err != nil {
|
|
||||||
t.Fatalf("io.Copy() error = %v", err)
|
|
||||||
}
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStatusCmd_RecognizesProviderFieldWithoutModelPrefix(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configPath := filepath.Join(tmpDir, "config.json")
|
|
||||||
workspace := filepath.Join(tmpDir, "workspace")
|
|
||||||
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
|
||||||
t.Fatalf("os.MkdirAll() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv(config.EnvConfig, configPath)
|
|
||||||
t.Setenv(config.EnvHome, tmpDir)
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
Agents: config.AgentsConfig{
|
|
||||||
Defaults: config.AgentDefaults{
|
|
||||||
ModelName: "gpt-5.4",
|
|
||||||
Workspace: workspace,
|
|
||||||
Provider: "openai",
|
|
||||||
MaxTokens: 65536,
|
|
||||||
Temperature: nil,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ModelList: []*config.ModelConfig{
|
|
||||||
{
|
|
||||||
ModelName: "gpt-5.4",
|
|
||||||
Provider: "openai",
|
|
||||||
Model: "gpt-5.4",
|
|
||||||
APIBase: "https://api.openai.com/v1",
|
|
||||||
APIKeys: config.SimpleSecureStrings("test-key"),
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ModelName: "qwen-plus",
|
|
||||||
Provider: "qwen",
|
|
||||||
Model: "qwen-plus",
|
|
||||||
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
||||||
APIKeys: config.SimpleSecureStrings("test-key"),
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
|
||||||
t.Fatalf("config.SaveConfig() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
output := captureStdout(t, statusCmd)
|
|
||||||
|
|
||||||
if !strings.Contains(output, "OpenAI API: \u2713") {
|
|
||||||
t.Fatalf("status output missing OpenAI provider: %s", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "Qwen API: \u2713") {
|
|
||||||
t.Fatalf("status output missing Qwen provider: %s", output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
package version
|
package version
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"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/cmd/picoclaw/internal/cliui"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -22,6 +23,12 @@ func NewVersionCommand() *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
func printVersion() {
|
func printVersion() {
|
||||||
|
fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion())
|
||||||
build, goVer := config.FormatBuildInfo()
|
build, goVer := config.FormatBuildInfo()
|
||||||
cliui.PrintVersion(internal.Logo, "picoclaw "+config.FormatVersion(), build, goVer)
|
if build != "" {
|
||||||
|
fmt.Printf(" Build: %s\n", build)
|
||||||
|
}
|
||||||
|
if goVer != "" {
|
||||||
|
fmt.Printf(" Go: %s\n", goVer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,8 @@ import (
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
|
||||||
"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/mcp"
|
|
||||||
"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/model"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
|
||||||
|
|
@ -30,57 +28,15 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/updater"
|
"github.com/sipeed/picoclaw/pkg/updater"
|
||||||
)
|
)
|
||||||
|
|
||||||
var rootNoColor bool
|
|
||||||
|
|
||||||
func syncCliUIColor(root *cobra.Command) {
|
|
||||||
no, _ := root.PersistentFlags().GetBool("no-color")
|
|
||||||
cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb")
|
|
||||||
}
|
|
||||||
|
|
||||||
// earlyColorDisabled matches lipgloss/banner behavior from env and argv before Cobra parses flags.
|
|
||||||
func earlyColorDisabled() bool {
|
|
||||||
if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for i := 1; i < len(os.Args); i++ {
|
|
||||||
arg := os.Args[i]
|
|
||||||
if arg == "--no-color" || arg == "--no-color=true" || arg == "--no-color=1" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPicoclawCommand() *cobra.Command {
|
func NewPicoclawCommand() *cobra.Command {
|
||||||
short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo)
|
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion())
|
||||||
long := fmt.Sprintf(`%s PicoClaw is a lightweight personal AI assistant.
|
|
||||||
|
|
||||||
Version: %s`, internal.Logo, config.FormatVersion())
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "picoclaw",
|
Use: "picoclaw",
|
||||||
Short: short,
|
Short: short,
|
||||||
Long: long,
|
Example: "picoclaw version",
|
||||||
Example: `picoclaw version
|
|
||||||
picoclaw onboard
|
|
||||||
picoclaw --no-color status`,
|
|
||||||
SilenceErrors: true,
|
|
||||||
// Avoid plain UsageString() on stderr/stdout when a command fails; cliui
|
|
||||||
// renders matching panels on stderr instead.
|
|
||||||
SilenceUsage: true,
|
|
||||||
PersistentPreRun: func(c *cobra.Command, _ []string) {
|
|
||||||
syncCliUIColor(c.Root())
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.PersistentFlags().BoolVar(&rootNoColor, "no-color", false,
|
|
||||||
"Disable colors (boxed layout unchanged)")
|
|
||||||
|
|
||||||
cmd.SetHelpFunc(func(c *cobra.Command, _ []string) {
|
|
||||||
syncCliUIColor(c.Root())
|
|
||||||
fmt.Fprint(c.OutOrStdout(), cliui.RenderCommandHelp(c))
|
|
||||||
})
|
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
onboard.NewOnboardCommand(),
|
onboard.NewOnboardCommand(),
|
||||||
agent.NewAgentCommand(),
|
agent.NewAgentCommand(),
|
||||||
|
|
@ -88,7 +44,6 @@ picoclaw --no-color status`,
|
||||||
gateway.NewGatewayCommand(),
|
gateway.NewGatewayCommand(),
|
||||||
status.NewStatusCommand(),
|
status.NewStatusCommand(),
|
||||||
cron.NewCronCommand(),
|
cron.NewCronCommand(),
|
||||||
mcp.NewMCPCommand(),
|
|
||||||
migrate.NewMigrateCommand(),
|
migrate.NewMigrateCommand(),
|
||||||
skills.NewSkillsCommand(),
|
skills.NewSkillsCommand(),
|
||||||
model.NewModelCommand(),
|
model.NewModelCommand(),
|
||||||
|
|
@ -110,31 +65,17 @@ const (
|
||||||
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
||||||
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
|
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
|
||||||
"\033[0m\r\n"
|
"\033[0m\r\n"
|
||||||
plainBanner = "\r\n" +
|
|
||||||
"██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
|
||||||
"██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
|
||||||
"██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
|
|
||||||
"██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
|
|
||||||
"██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
|
||||||
"╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
|
|
||||||
"\r\n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cliui.Init(earlyColorDisabled())
|
fmt.Printf("%s", banner)
|
||||||
|
|
||||||
if earlyColorDisabled() {
|
tz_env := os.Getenv("TZ")
|
||||||
fmt.Print(plainBanner)
|
if tz_env != "" {
|
||||||
} else {
|
fmt.Println("TZ environment:", tz_env)
|
||||||
fmt.Printf("%s", banner)
|
zoneinfo_env := os.Getenv("ZONEINFO")
|
||||||
}
|
fmt.Println("ZONEINFO environment:", zoneinfo_env)
|
||||||
|
loc, err := time.LoadLocation(tz_env)
|
||||||
tzEnv := os.Getenv("TZ")
|
|
||||||
if tzEnv != "" {
|
|
||||||
fmt.Println("TZ environment:", tzEnv)
|
|
||||||
zoneinfoEnv := os.Getenv("ZONEINFO")
|
|
||||||
fmt.Println("ZONEINFO environment:", zoneinfoEnv)
|
|
||||||
loc, err := time.LoadLocation(tzEnv)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Error loading time zone:", err)
|
fmt.Println("Error loading time zone:", err)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -144,10 +85,7 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := NewPicoclawCommand()
|
cmd := NewPicoclawCommand()
|
||||||
last, err := cmd.ExecuteC()
|
if err := cmd.Execute(); err != nil {
|
||||||
if err != nil {
|
|
||||||
syncCliUIColor(cmd)
|
|
||||||
fmt.Fprint(os.Stderr, cliui.FormatCLIError(err.Error(), last))
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package main
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
@ -18,22 +17,20 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo)
|
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion())
|
||||||
longHas := strings.Contains(cmd.Long, config.FormatVersion())
|
|
||||||
|
|
||||||
assert.Equal(t, "picoclaw", cmd.Use)
|
assert.Equal(t, "picoclaw", cmd.Use)
|
||||||
assert.Equal(t, short, cmd.Short)
|
assert.Equal(t, short, cmd.Short)
|
||||||
assert.True(t, longHas)
|
|
||||||
|
|
||||||
assert.True(t, cmd.HasSubCommands())
|
assert.True(t, cmd.HasSubCommands())
|
||||||
assert.True(t, cmd.HasAvailableSubCommands())
|
assert.True(t, cmd.HasAvailableSubCommands())
|
||||||
|
|
||||||
assert.True(t, cmd.PersistentFlags().Lookup("no-color") != nil)
|
assert.False(t, cmd.HasFlags())
|
||||||
|
|
||||||
assert.Nil(t, cmd.Run)
|
assert.Nil(t, cmd.Run)
|
||||||
assert.Nil(t, cmd.RunE)
|
assert.Nil(t, cmd.RunE)
|
||||||
|
|
||||||
assert.NotNil(t, cmd.PersistentPreRun)
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
assert.Nil(t, cmd.PersistentPostRun)
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
|
||||||
allowedCommands := []string{
|
allowedCommands := []string{
|
||||||
|
|
@ -41,7 +38,6 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||||
"auth",
|
"auth",
|
||||||
"cron",
|
"cron",
|
||||||
"gateway",
|
"gateway",
|
||||||
"mcp",
|
|
||||||
"migrate",
|
"migrate",
|
||||||
"model",
|
"model",
|
||||||
"onboard",
|
"onboard",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"version": 3,
|
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
|
|
@ -12,35 +11,23 @@
|
||||||
"summarize_message_threshold": 20,
|
"summarize_message_threshold": 20,
|
||||||
"summarize_token_percent": 75,
|
"summarize_token_percent": 75,
|
||||||
"split_on_marker": false,
|
"split_on_marker": false,
|
||||||
"max_llm_retries": 2,
|
|
||||||
"llm_retry_backoff_secs": 2,
|
|
||||||
"tool_feedback": {
|
"tool_feedback": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"max_args_length": 300,
|
"max_args_length": 300
|
||||||
"separate_messages": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"evolution": {
|
|
||||||
"enabled": false,
|
|
||||||
"mode": "observe",
|
|
||||||
"state_dir": "",
|
|
||||||
"min_task_count": 2,
|
|
||||||
"min_success_ratio": 0.7,
|
|
||||||
"cold_path_trigger": "after_turn",
|
|
||||||
"cold_path_times": []
|
|
||||||
},
|
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_keys": ["sk-your-openai-key"],
|
"api_key": "sk-your-openai-key",
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"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_keys": ["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"
|
"thinking_level": "high"
|
||||||
},
|
},
|
||||||
|
|
@ -48,24 +35,23 @@
|
||||||
"_comment": "Anthropic Messages API - use native format for direct Anthropic API access",
|
"_comment": "Anthropic Messages API - use native format for direct Anthropic API access",
|
||||||
"model_name": "claude-opus-4-6",
|
"model_name": "claude-opus-4-6",
|
||||||
"model": "anthropic-messages/claude-opus-4-6",
|
"model": "anthropic-messages/claude-opus-4-6",
|
||||||
"api_keys": ["sk-ant-your-key"],
|
"api_key": "sk-ant-your-key",
|
||||||
"api_base": "https://api.anthropic.com"
|
"api_base": "https://api.anthropic.com"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gemini",
|
"model_name": "gemini",
|
||||||
"_comment": "Optional: set \"tool_schema_transform\": \"simple\" for providers that reject complex tool JSON Schema.",
|
|
||||||
"model": "antigravity/gemini-2.0-flash",
|
"model": "antigravity/gemini-2.0-flash",
|
||||||
"auth_method": "oauth"
|
"auth_method": "oauth"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "deepseek",
|
"model_name": "deepseek",
|
||||||
"model": "deepseek/deepseek-chat",
|
"model": "deepseek/deepseek-chat",
|
||||||
"api_keys": ["sk-your-deepseek-key"]
|
"api_key": "sk-your-deepseek-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "venice-uncensored",
|
"model_name": "venice-uncensored",
|
||||||
"model": "venice/venice-uncensored",
|
"model": "venice/venice-uncensored",
|
||||||
"api_keys": ["your-venice-api-key"]
|
"api_key": "your-venice-api-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "lmstudio-local",
|
"model_name": "lmstudio-local",
|
||||||
|
|
@ -74,134 +60,114 @@
|
||||||
{
|
{
|
||||||
"model_name": "longcat",
|
"model_name": "longcat",
|
||||||
"model": "longcat/LongCat-Flash-Thinking",
|
"model": "longcat/LongCat-Flash-Thinking",
|
||||||
"api_keys": ["your-longcat-api-key"]
|
"api_key": "your-longcat-api-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "modelscope-qwen",
|
"model_name": "modelscope-qwen",
|
||||||
"model": "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
"model": "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||||
"api_keys": ["your-modelscope-access-token"],
|
"api_key": "your-modelscope-access-token",
|
||||||
"api_base": "https://api-inference.modelscope.cn/v1"
|
"api_base": "https://api-inference.modelscope.cn/v1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "azure-gpt5",
|
"model_name": "azure-gpt5",
|
||||||
"model": "azure/my-gpt5-deployment",
|
"model": "azure/my-gpt5-deployment",
|
||||||
"api_keys": ["your-azure-api-key"],
|
"api_key": "your-azure-api-key",
|
||||||
"api_base": "https://your-resource.openai.azure.com"
|
"api_base": "https://your-resource.openai.azure.com"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "loadbalanced-gpt-5.4",
|
"model_name": "loadbalanced-gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_keys": ["sk-key1"],
|
"api_key": "sk-key1",
|
||||||
"api_base": "https://api1.example.com/v1"
|
"api_base": "https://api1.example.com/v1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "loadbalanced-gpt-5.4",
|
"model_name": "loadbalanced-gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_keys": ["sk-key2"],
|
"api_key": "sk-key2",
|
||||||
"api_base": "https://api2.example.com/v1"
|
"api_base": "https://api2.example.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"channel_list": {
|
"channels": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"type": "telegram",
|
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||||
|
"base_url": "",
|
||||||
|
"proxy": "",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
|
"use_markdown_v2": false,
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": "",
|
||||||
"settings": {
|
"streaming": {
|
||||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
"enabled": true
|
||||||
"base_url": "",
|
|
||||||
"proxy": "",
|
|
||||||
"use_markdown_v2": false,
|
|
||||||
"streaming": {
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"type": "discord",
|
"token": "YOUR_DISCORD_BOT_TOKEN",
|
||||||
|
"proxy": "",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
"mention_only": false
|
"mention_only": false
|
||||||
},
|
},
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": ""
|
||||||
"settings": {
|
|
||||||
"token": "YOUR_DISCORD_BOT_TOKEN",
|
|
||||||
"proxy": ""
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"qq": {
|
"qq": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"type": "qq",
|
"app_id": "YOUR_QQ_APP_ID",
|
||||||
|
"app_secret": "YOUR_QQ_APP_SECRET",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": ""
|
||||||
"settings": {
|
|
||||||
"app_id": "YOUR_QQ_APP_ID",
|
|
||||||
"app_secret": "YOUR_QQ_APP_SECRET"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"maixcam": {
|
"maixcam": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"type": "maixcam",
|
"host": "0.0.0.0",
|
||||||
|
"port": 18790,
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": ""
|
||||||
"settings": {
|
|
||||||
"host": "0.0.0.0",
|
|
||||||
"port": 18790
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"type": "whatsapp",
|
"bridge_url": "ws://localhost:3001",
|
||||||
|
"use_native": false,
|
||||||
|
"session_store_path": "",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": ""
|
||||||
"settings": {
|
|
||||||
"bridge_url": "ws://localhost:3001",
|
|
||||||
"use_native": false,
|
|
||||||
"session_store_path": ""
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"feishu": {
|
"feishu": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"type": "feishu",
|
"app_id": "",
|
||||||
|
"app_secret": "",
|
||||||
|
"encrypt_key": "",
|
||||||
|
"verification_token": "",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reasoning_channel_id": "",
|
|
||||||
"placeholder": {
|
"placeholder": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"text": ["Thinking...", "Processing...", "Typing..."]
|
"text": ["Thinking...", "Processing...", "Typing..."]
|
||||||
},
|
},
|
||||||
"settings": {
|
"reasoning_channel_id": "",
|
||||||
"app_id": "",
|
"random_reaction_emoji": [],
|
||||||
"app_secret": "",
|
"is_lark": false
|
||||||
"encrypt_key": "",
|
|
||||||
"verification_token": "",
|
|
||||||
"random_reaction_emoji": [],
|
|
||||||
"is_lark": false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"dingtalk": {
|
"dingtalk": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"type": "dingtalk",
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": ""
|
||||||
"settings": {
|
|
||||||
"client_id": "YOUR_CLIENT_ID",
|
|
||||||
"client_secret": "YOUR_CLIENT_SECRET"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"slack": {
|
"slack": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"type": "slack",
|
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
||||||
|
"app_token": "xapp-YOUR-APP-TOKEN",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": ""
|
||||||
"settings": {
|
|
||||||
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
|
||||||
"app_token": "xapp-YOUR-APP-TOKEN"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"matrix": {
|
"matrix": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"type": "matrix",
|
"homeserver": "https://matrix.org",
|
||||||
|
"user_id": "@your-bot:matrix.org",
|
||||||
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
"device_id": "",
|
||||||
|
"join_on_invite": true,
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
"mention_only": true
|
"mention_only": true
|
||||||
|
|
@ -211,82 +177,68 @@
|
||||||
"text": ["Thinking...", "Processing...", "Typing..."]
|
"text": ["Thinking...", "Processing...", "Typing..."]
|
||||||
},
|
},
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": "",
|
||||||
"settings": {
|
"crypto_database_path": "",
|
||||||
"homeserver": "https://matrix.org",
|
"crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY"
|
||||||
"user_id": "@your-bot:matrix.org",
|
|
||||||
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
|
||||||
"device_id": "",
|
|
||||||
"join_on_invite": true,
|
|
||||||
"crypto_database_path": "",
|
|
||||||
"crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"line": {
|
"line": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"type": "line",
|
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
||||||
|
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
|
||||||
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": ""
|
||||||
"settings": {
|
|
||||||
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
|
||||||
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
|
|
||||||
"webhook_path": "/webhook/line"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"onebot": {
|
"onebot": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"type": "onebot",
|
"ws_url": "ws://127.0.0.1:3001",
|
||||||
|
"access_token": "",
|
||||||
|
"reconnect_interval": 5,
|
||||||
|
"group_trigger_prefix": [],
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": ""
|
||||||
"group_trigger": {
|
|
||||||
"prefixes": []
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"ws_url": "ws://127.0.0.1:3001",
|
|
||||||
"access_token": "",
|
|
||||||
"reconnect_interval": 5
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"_comment": "WeCom AI Bot over WebSocket.",
|
"_comment": "WeCom AI Bot over WebSocket.",
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"type": "wecom",
|
"bot_id": "YOUR_BOT_ID",
|
||||||
|
"secret": "YOUR_SECRET",
|
||||||
|
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||||
|
"send_thinking_message": true,
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": ""
|
||||||
"settings": {
|
|
||||||
"bot_id": "YOUR_BOT_ID",
|
|
||||||
"secret": "YOUR_SECRET",
|
|
||||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
|
||||||
"send_thinking_message": true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"pico": {
|
"pico": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"type": "pico",
|
"token": "YOUR_PICO_TOKEN",
|
||||||
"allow_from": [],
|
"allow_token_query": false,
|
||||||
"settings": {
|
"allow_origins": [],
|
||||||
"token": "YOUR_PICO_TOKEN",
|
"ping_interval": 30,
|
||||||
"allow_token_query": false,
|
"read_timeout": 60,
|
||||||
"allow_origins": [],
|
"max_connections": 100,
|
||||||
"ping_interval": 30,
|
"allow_from": []
|
||||||
"read_timeout": 60,
|
|
||||||
"max_connections": 100
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"pico_client": {
|
"pico_client": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"type": "pico_client",
|
"url": "wss://remote-pico-server/pico/ws",
|
||||||
"allow_from": [],
|
"token": "YOUR_PICO_TOKEN",
|
||||||
"settings": {
|
"session_id": "",
|
||||||
"url": "wss://remote-pico-server/pico/ws",
|
"ping_interval": 30,
|
||||||
"token": "YOUR_PICO_TOKEN",
|
"read_timeout": 60,
|
||||||
"session_id": "",
|
"allow_from": []
|
||||||
"ping_interval": 30,
|
|
||||||
"read_timeout": 60
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"irc": {
|
"irc": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"type": "irc",
|
"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": [],
|
"allow_from": [],
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
"mention_only": true
|
"mention_only": true
|
||||||
|
|
@ -294,20 +246,7 @@
|
||||||
"typing": {
|
"typing": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": ""
|
||||||
"settings": {
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
@ -316,6 +255,7 @@
|
||||||
"web": {
|
"web": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"prefer_native": true,
|
"prefer_native": true,
|
||||||
|
"fetch_limit_bytes": 10485760,
|
||||||
"format": "plaintext",
|
"format": "plaintext",
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
@ -329,19 +269,8 @@
|
||||||
"base_url": "",
|
"base_url": "",
|
||||||
"max_results": 0
|
"max_results": 0
|
||||||
},
|
},
|
||||||
"provider": "auto",
|
|
||||||
"sogou": {
|
|
||||||
"enabled": true,
|
|
||||||
"max_results": 5
|
|
||||||
},
|
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"max_results": 5
|
|
||||||
},
|
|
||||||
"gemini": {
|
|
||||||
"enabled": false,
|
|
||||||
"api_key": "",
|
|
||||||
"model": "gemini-2.5-flash",
|
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"perplexity": {
|
"perplexity": {
|
||||||
|
|
@ -453,16 +382,9 @@
|
||||||
"timeout": 0,
|
"timeout": 0,
|
||||||
"max_zip_size": 0,
|
"max_zip_size": 0,
|
||||||
"max_response_size": 0
|
"max_response_size": 0
|
||||||
},
|
|
||||||
"github": {
|
|
||||||
"enabled": true,
|
|
||||||
"base_url": "https://github.com",
|
|
||||||
"auth_token": "",
|
|
||||||
"proxy": "http://127.0.0.1:7891"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"github": {
|
"github": {
|
||||||
"base_url": "https://github.com",
|
|
||||||
"proxy": "http://127.0.0.1:7891",
|
"proxy": "http://127.0.0.1:7891",
|
||||||
"token": ""
|
"token": ""
|
||||||
},
|
},
|
||||||
|
|
@ -502,9 +424,6 @@
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"mode": "bytes"
|
"mode": "bytes"
|
||||||
},
|
},
|
||||||
"serial": {
|
|
||||||
"enabled": false
|
|
||||||
},
|
|
||||||
"send_tts": {
|
"send_tts": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
|
|
@ -544,18 +463,9 @@
|
||||||
"approval_timeout_ms": 60000
|
"approval_timeout_ms": 60000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"events": {
|
|
||||||
"logging": {
|
|
||||||
"enabled": true,
|
|
||||||
"include": ["agent.*"],
|
|
||||||
"exclude": [],
|
|
||||||
"min_severity": "info",
|
|
||||||
"include_payload": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
|
"_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
|
||||||
"host": "localhost",
|
"host": "127.0.0.1",
|
||||||
"port": 18790,
|
"port": 18790,
|
||||||
"hot_reload": false,
|
"hot_reload": false,
|
||||||
"log_level": "fatal"
|
"log_level": "fatal"
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,18 @@ RUN apk add --no-cache ca-certificates tzdata curl
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget -q --spider http://localhost:18790/health || exit 1
|
CMD wget -q --spider http://localhost:18790/health || exit 1
|
||||||
|
|
||||||
# Copy binary and first-run entrypoint (same as release image).
|
# Copy binary
|
||||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||||
COPY docker/entrypoint.sh /entrypoint.sh
|
|
||||||
RUN chmod +x /entrypoint.sh
|
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
# Create non-root user and group
|
||||||
|
RUN addgroup -g 1000 picoclaw && \
|
||||||
|
adduser -D -u 1000 -G picoclaw picoclaw
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER picoclaw
|
||||||
|
|
||||||
|
# Run onboard to create initial directories and config
|
||||||
|
RUN /usr/local/bin/picoclaw onboard
|
||||||
|
|
||||||
|
ENTRYPOINT ["picoclaw"]
|
||||||
|
CMD ["gateway"]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Stage 1: Build the picoclaw binary
|
# Stage 1: Build the picoclaw binary
|
||||||
# ============================================================
|
# ============================================================
|
||||||
FROM golang:1.25-alpine AS builder
|
FROM golang:1.26.0-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache git make
|
RUN apk add --no-cache git make
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
||||||
COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher
|
COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher
|
||||||
|
COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui
|
||||||
|
|
||||||
ENTRYPOINT ["picoclaw-launcher"]
|
ENTRYPOINT ["picoclaw-launcher"]
|
||||||
CMD ["-console", "-public", "-no-browser"]
|
CMD ["-console", "-public", "-no-browser"]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Stage 1: Build the picoclaw binary
|
# Stage 1: Build the picoclaw binary
|
||||||
# ============================================================
|
# ============================================================
|
||||||
FROM golang:1.25-alpine AS builder
|
FROM golang:1.26.0-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache git make
|
RUN apk add --no-cache git make
|
||||||
|
|
||||||
|
|
@ -48,13 +48,20 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
# Copy binary
|
# Copy binary
|
||||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||||
|
|
||||||
|
# Reuse existing node user (UID/GID 1000) — rename to picoclaw
|
||||||
|
RUN deluser node 2>/dev/null; delgroup node 2>/dev/null; \
|
||||||
|
addgroup -g 1000 picoclaw 2>/dev/null; \
|
||||||
|
adduser -D -u 1000 -G picoclaw -h /home/picoclaw picoclaw 2>/dev/null || true
|
||||||
|
|
||||||
|
USER picoclaw
|
||||||
|
|
||||||
# Run onboard to create initial directories and config
|
# Run onboard to create initial directories and config
|
||||||
RUN /usr/local/bin/picoclaw onboard
|
RUN /usr/local/bin/picoclaw onboard
|
||||||
|
|
||||||
# Copy default workspace
|
# Copy default workspace
|
||||||
COPY workspace/ /root/.picoclaw/workspace/
|
COPY --chown=picoclaw:picoclaw workspace/ /home/picoclaw/.picoclaw/workspace/
|
||||||
|
|
||||||
VOLUME /root/.picoclaw/workspace
|
VOLUME /home/picoclaw/.picoclaw/workspace
|
||||||
|
|
||||||
ENTRYPOINT ["picoclaw"]
|
ENTRYPOINT ["picoclaw"]
|
||||||
CMD ["gateway"]
|
CMD ["gateway"]
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
# ============================================================
|
|
||||||
# Stage 1: Build frontend assets (Node.js + pnpm)
|
|
||||||
# ============================================================
|
|
||||||
FROM node:24-alpine3.23 AS frontend
|
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
WORKDIR /src/web/frontend
|
|
||||||
|
|
||||||
# Cache frontend dependencies
|
|
||||||
COPY web/frontend/package.json web/frontend/pnpm-lock.yaml ./
|
|
||||||
RUN CI=true pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# Build frontend
|
|
||||||
COPY web/frontend/ ./
|
|
||||||
RUN pnpm build:backend
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Stage 2: Build Go binaries (picoclaw + picoclaw-launcher)
|
|
||||||
# ============================================================
|
|
||||||
FROM golang:1.25-alpine AS builder
|
|
||||||
|
|
||||||
RUN apk add --no-cache git make
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
# Cache Go dependencies
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# Copy source
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Copy pre-built frontend assets into the backend embed directory
|
|
||||||
COPY --from=frontend /src/web/backend/dist web/backend/dist
|
|
||||||
|
|
||||||
# Build picoclaw binary (includes go generate)
|
|
||||||
RUN make build
|
|
||||||
|
|
||||||
# Build picoclaw-launcher binary (frontend already built in stage 1)
|
|
||||||
# Mirror ldflags from web/Makefile to inject version metadata
|
|
||||||
RUN CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config && \
|
|
||||||
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo dev) && \
|
|
||||||
GIT_COMMIT=$(git rev-parse --short=8 HEAD 2>/dev/null || echo dev) && \
|
|
||||||
BUILD_TIME=$(date +%FT%T%z) && \
|
|
||||||
GO_VERSION=$(go env GOVERSION) && \
|
|
||||||
CGO_ENABLED=0 go build -v -tags goolm,stdjson \
|
|
||||||
-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" \
|
|
||||||
-o build/picoclaw-launcher ./web/backend/
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Stage 3: Minimal runtime image
|
|
||||||
# ============================================================
|
|
||||||
FROM alpine:3.23
|
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata curl
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD wget -q --spider http://localhost:18790/health || exit 1
|
|
||||||
|
|
||||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
|
||||||
COPY --from=builder /src/build/picoclaw-launcher /usr/local/bin/picoclaw-launcher
|
|
||||||
|
|
||||||
ENTRYPOINT ["picoclaw-launcher"]
|
|
||||||
CMD ["-console", "-public", "-no-browser"]
|
|
||||||
|
|
@ -4,9 +4,6 @@ services:
|
||||||
# docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello"
|
# docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello"
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-agent:
|
picoclaw-agent:
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/Dockerfile
|
|
||||||
image: docker.io/sipeed/picoclaw:latest
|
image: docker.io/sipeed/picoclaw:latest
|
||||||
container_name: picoclaw-agent
|
container_name: picoclaw-agent
|
||||||
profiles:
|
profiles:
|
||||||
|
|
@ -25,9 +22,6 @@ services:
|
||||||
# docker compose -f docker/docker-compose.yml --profile gateway up
|
# docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-gateway:
|
picoclaw-gateway:
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/Dockerfile
|
|
||||||
image: docker.io/sipeed/picoclaw:latest
|
image: docker.io/sipeed/picoclaw:latest
|
||||||
container_name: picoclaw-gateway
|
container_name: picoclaw-gateway
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
@ -44,9 +38,6 @@ services:
|
||||||
# docker compose -f docker/docker-compose.yml --profile launcher up
|
# docker compose -f docker/docker-compose.yml --profile launcher up
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-launcher:
|
picoclaw-launcher:
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/Dockerfile.launcher
|
|
||||||
image: docker.io/sipeed/picoclaw:launcher
|
image: docker.io/sipeed/picoclaw:launcher
|
||||||
container_name: picoclaw-launcher
|
container_name: picoclaw-launcher
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,4 @@ if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.js
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove stale PID file from a previous container run.
|
|
||||||
# After docker kill / OOM / crash the PID file may linger on the bind-mounted
|
|
||||||
# volume and block the next gateway start (the recorded PID could collide with
|
|
||||||
# an unrelated process inside the new container).
|
|
||||||
rm -f "${HOME}/.picoclaw/.picoclaw.pid"
|
|
||||||
|
|
||||||
exec picoclaw gateway "$@"
|
exec picoclaw gateway "$@"
|
||||||
|
|
|
||||||
|
|
@ -689,7 +689,7 @@ case "your-provider":
|
||||||
{
|
{
|
||||||
"model_name": "your-model",
|
"model_name": "your-model",
|
||||||
"model": "your-provider/model-name",
|
"model": "your-provider/model-name",
|
||||||
"api_keys": ["your-api-key"],
|
"api_key": "your-api-key",
|
||||||
"api_base": "https://api.your-provider.com/v1"
|
"api_base": "https://api.your-provider.com/v1"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -723,7 +723,7 @@ picoclaw agent -m "Hello" --model your-model
|
||||||
export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
|
export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
|
||||||
|
|
||||||
# Override provider settings
|
# Override provider settings
|
||||||
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_keys":["..."]}]'
|
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
132
docs/README.md
132
docs/README.md
|
|
@ -1,132 +0,0 @@
|
||||||
# PicoClaw Documentation
|
|
||||||
|
|
||||||
PicoClaw documentation is organized by document type first and language second.
|
|
||||||
|
|
||||||
This file describes the recommended documentation layout, how translated files should be named, and what `make lint-docs` currently checks locally.
|
|
||||||
|
|
||||||
These conventions are intended as contributor guidance for new or moved docs. Existing docs may still have historical exceptions, and `make lint-docs` only checks a common subset of the patterns described here.
|
|
||||||
|
|
||||||
## Reader Navigation
|
|
||||||
|
|
||||||
If you are browsing docs rather than reorganizing them, start with these directory indexes:
|
|
||||||
|
|
||||||
- [Guides](guides/README.md): setup, configuration, provider, and workflow guides.
|
|
||||||
- [Reference](reference/README.md): precise configuration and behavior reference.
|
|
||||||
- [Operations](operations/README.md): debugging and troubleshooting material.
|
|
||||||
- [Security](security/README.md): security-focused guides and controls.
|
|
||||||
- [Architecture](architecture/README.md): implementation notes and internal design docs.
|
|
||||||
- [Migration](migration/README.md): upgrade and migration notes.
|
|
||||||
|
|
||||||
For channel-specific setup, start with [Chat Apps Configuration](guides/chat-apps.md) and then drill into `docs/channels/<name>/README.md` as needed.
|
|
||||||
|
|
||||||
## Principles
|
|
||||||
|
|
||||||
- Choose the document type directory first. Do not create language buckets such as `docs/zh/` or `docs/fr/`.
|
|
||||||
- Keep each translated document next to its English source document.
|
|
||||||
- Use English as the base filename with no locale suffix.
|
|
||||||
- Use lowercase locale suffixes for translations, for example `configuration.zh.md` or `README.pt-br.md`.
|
|
||||||
- Keep module-specific docs next to the code they describe instead of moving them into `docs/`.
|
|
||||||
|
|
||||||
## Recommended Directories
|
|
||||||
|
|
||||||
- `README.md`: English project entry document at the repository root.
|
|
||||||
- `docs/project/`: translated project entry documents such as `README.zh.md` and `CONTRIBUTING.zh.md`.
|
|
||||||
- `docs/guides/`: setup and usage guides.
|
|
||||||
- `docs/reference/`: reference material and detailed configuration docs.
|
|
||||||
- `docs/operations/`: debugging and troubleshooting docs.
|
|
||||||
- `docs/security/`: security-related documentation.
|
|
||||||
- `docs/architecture/`: architecture and internal design notes.
|
|
||||||
- `docs/channels/`: channel-specific integration guides.
|
|
||||||
- `docs/design/`: design proposals and investigations.
|
|
||||||
- `docs/migration/`: migration notes.
|
|
||||||
|
|
||||||
## Recommended Naming
|
|
||||||
|
|
||||||
- English documents use the base filename:
|
|
||||||
- `README.md`
|
|
||||||
- `configuration.md`
|
|
||||||
- Translations use `.<locale>.md`:
|
|
||||||
- `README.zh.md`
|
|
||||||
- `configuration.fr.md`
|
|
||||||
- `README.pt-br.md`
|
|
||||||
- Code-adjacent translated READMEs follow the same rule:
|
|
||||||
- `pkg/audio/asr/README.zh.md`
|
|
||||||
- `pkg/isolation/README.zh.md`
|
|
||||||
|
|
||||||
## Common Patterns To Avoid
|
|
||||||
|
|
||||||
- Root-level translated entry docs such as `README.zh.md` or `CONTRIBUTING.fr.md`
|
|
||||||
- Use `docs/project/README.zh.md` or `docs/project/CONTRIBUTING.fr.md` instead.
|
|
||||||
- Language directories under `docs/` such as `docs/zh/`, `docs/ZH/`, `docs/ja/`, or `docs/fr/`
|
|
||||||
- Use `docs/<type>/<name>.<locale>.md` instead.
|
|
||||||
- Nested locale buckets such as `docs/guides/zh/configuration.md` or `docs/channels/telegram/zh/README.md`
|
|
||||||
- Keep translations beside the English source file instead.
|
|
||||||
- Legacy translation filenames such as `README_zh.md` or `README_CN.md`
|
|
||||||
- Use `README.zh.md`.
|
|
||||||
- Non-canonical locale suffixes such as `configuration_zh.md` or `configuration.ZH.md`
|
|
||||||
- Use lowercase `.<locale>.md`, for example `configuration.zh.md`.
|
|
||||||
|
|
||||||
## Translation Placement
|
|
||||||
|
|
||||||
- For docs under `docs/guides`, `docs/reference`, `docs/operations`, `docs/security`, `docs/architecture`, `docs/channels`, and `docs/migration`, keep translations beside the English source file.
|
|
||||||
- For project entry translations, keep translated files in `docs/project/` and keep the English source in the repository root.
|
|
||||||
- In most cases, each translated file should have an English source document:
|
|
||||||
- `docs/guides/configuration.zh.md` usually sits beside `docs/guides/configuration.md`
|
|
||||||
- `docs/project/README.zh.md` usually corresponds to `README.md`
|
|
||||||
- Exception: `docs/design/` may contain locale-specific working notes without an English source document. The naming rules still apply there.
|
|
||||||
|
|
||||||
## Code-Adjacent Docs
|
|
||||||
|
|
||||||
Keep documentation next to the implementation when it primarily describes a package, command, example, or subproject.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- `pkg/**/README.md`
|
|
||||||
- `cmd/**/README.md`
|
|
||||||
- `web/README.md`
|
|
||||||
- `examples/**/README.md`
|
|
||||||
|
|
||||||
These files still follow the same translation naming rules.
|
|
||||||
|
|
||||||
## Adding a New Document
|
|
||||||
|
|
||||||
1. Pick the correct document type directory.
|
|
||||||
2. Create the English source file first.
|
|
||||||
3. Add translated siblings after the English source exists when that source is part of the same docs set.
|
|
||||||
4. Update links from existing docs when the new doc becomes a navigation target.
|
|
||||||
5. Run `make lint-docs` locally when adding or moving docs.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
- New setup guide:
|
|
||||||
- `docs/guides/launcher-setup.md`
|
|
||||||
- `docs/guides/launcher-setup.zh.md`
|
|
||||||
- New security guide:
|
|
||||||
- `docs/security/token-rotation.md`
|
|
||||||
- New translated package README:
|
|
||||||
- `pkg/channels/README.zh.md`
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make lint-docs
|
|
||||||
```
|
|
||||||
|
|
||||||
The local docs linter currently checks these common cases:
|
|
||||||
|
|
||||||
- no root-level translated `README` or `CONTRIBUTING` files
|
|
||||||
- no `docs/<locale>/` language buckets, regardless of case
|
|
||||||
- no nested locale buckets under typed docs directories
|
|
||||||
- no legacy `README_*.md` filenames
|
|
||||||
- no non-canonical translation-like filenames such as `_zh.md` or `.ZH.md`
|
|
||||||
- no extra Markdown files directly under `docs/` except `docs/README.md`
|
|
||||||
- every translated Markdown file has a matching English source file
|
|
||||||
- except for locale-specific working notes under `docs/design/`
|
|
||||||
|
|
||||||
`make lint-docs` is a local consistency check for common naming and placement mistakes. It helps contributors stay close to the recommended layout, but it is not intended to describe every acceptable documentation pattern in the repository.
|
|
||||||
|
|
||||||
When a check fails, `make lint-docs` prints the failing path, the reason, and a suggested fix.
|
|
||||||
|
|
||||||
If you change these recommendations or want the local linter to reflect them more closely, update this file and `scripts/lint-docs.sh` together.
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
# Architecture
|
|
||||||
|
|
||||||
Internal architecture notes for major runtime mechanisms and subsystem design.
|
|
||||||
|
|
||||||
- [Steering](steering.md): injecting messages into a running agent loop between tool calls.
|
|
||||||
- [SubTurn Mechanism](subturn.md): sub-agent coordination, concurrency control, and lifecycle handling.
|
|
||||||
- [Session System](session-system.md): session scope allocation, JSONL persistence, alias compatibility, and migration. ([ZH](session-system.zh.md))
|
|
||||||
- [Routing System](routing-system.md): agent dispatch, session policy selection, and light/heavy model routing. ([ZH](routing-system.zh.md))
|
|
||||||
- [Runtime Events](runtime-events.md): runtime event envelope, centralized event logging, filters, and examples. ([ZH](runtime-events.zh.md))
|
|
||||||
- [Agent Self-Evolution](agent-self-evolution.md): learning records, draft generation, application modes, and state layout.
|
|
||||||
- [Hook System Guide](hooks/README.md): current hook architecture and protocol details.
|
|
||||||
- [Agent Refactor](agent-refactor/README.md): notes and checkpoints for the agent refactor work.
|
|
||||||
|
|
||||||
For proposal-style or exploratory docs, also see [`../design/`](../design/).
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
# Agent File Rename Plan
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Unify `pkg/agent/` package file naming to resolve the `loop_*` prefix naming confusion and unclear responsibility boundaries.
|
|
||||||
|
|
||||||
## Change Overview
|
|
||||||
|
|
||||||
### File Renames (12 files)
|
|
||||||
|
|
||||||
| Original | New | Description |
|
|
||||||
|----------|-----|-------------|
|
|
||||||
| `loop.go` | `agent.go` | AgentLoop main body + lifecycle methods |
|
|
||||||
| `loop_message.go` | `agent_message.go` | Message handling and routing |
|
|
||||||
| `loop_outbound.go` | `agent_outbound.go` | Response publishing |
|
|
||||||
| `loop_event.go` | `agent_event.go` | Event system |
|
|
||||||
| `loop_command.go` | `agent_command.go` | Command processing |
|
|
||||||
| `loop_steering.go` | `agent_steering.go` | Steering message handling |
|
|
||||||
| `loop_transcribe.go` | `agent_transcribe.go` | Audio transcription |
|
|
||||||
| `loop_media.go` | `agent_media.go` | Media processing |
|
|
||||||
| `loop_mcp.go` | `agent_mcp.go` | MCP initialization |
|
|
||||||
| `loop_utils.go` | `agent_utils.go` | Utility functions |
|
|
||||||
| `loop_inject.go` | `agent_inject.go` | Dependency injection |
|
|
||||||
| `loop_turn.go` | `turn_coord.go` | Turn coordinator |
|
|
||||||
|
|
||||||
### File Merges (2 → 1)
|
|
||||||
|
|
||||||
| Original | New | Description |
|
|
||||||
|----------|-----|-------------|
|
|
||||||
| `turn.go` + `turn_exec.go` | `turn_state.go` | Turn-related type definitions |
|
|
||||||
|
|
||||||
## Final File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
pkg/agent/
|
|
||||||
├── agent.go # AgentLoop + Run/Stop/Close lifecycle
|
|
||||||
├── agent_message.go # Message processing
|
|
||||||
├── agent_outbound.go # Response publishing
|
|
||||||
├── agent_event.go # Event system
|
|
||||||
├── agent_command.go # Command processing
|
|
||||||
├── agent_steering.go # Steering
|
|
||||||
├── agent_transcribe.go # Transcription
|
|
||||||
├── agent_media.go # Media processing
|
|
||||||
├── agent_mcp.go # MCP
|
|
||||||
├── agent_utils.go # Utility functions
|
|
||||||
├── agent_inject.go # Dependency injection
|
|
||||||
├── turn_coord.go # runTurn + coordinator
|
|
||||||
├── turn_state.go # turnState + turnExecution + Control + ToolControl + LLMPhase
|
|
||||||
├── pipeline.go # Pipeline struct + NewPipeline
|
|
||||||
├── pipeline_setup.go
|
|
||||||
├── pipeline_llm.go
|
|
||||||
├── pipeline_execute.go
|
|
||||||
└── pipeline_finalize.go
|
|
||||||
```
|
|
||||||
|
|
||||||
## Naming Convention
|
|
||||||
|
|
||||||
| Prefix | Content | Example |
|
|
||||||
|--------|---------|---------|
|
|
||||||
| `agent_*` | AgentLoop method files | `agent_message.go`, `agent_event.go` |
|
|
||||||
| `turn_*` | Turn lifecycle related | `turn_coord.go`, `turn_state.go` |
|
|
||||||
| `pipeline_*` | Pipeline methods | `pipeline_setup.go`, `pipeline_llm.go` |
|
|
||||||
| `context_*` | Context management | `context_manager.go`, `context_legacy.go` |
|
|
||||||
| `hook_*` | Hook system | `hook_process.go`, `hook_mount.go` |
|
|
||||||
|
|
||||||
## Architecture Layers
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ AgentLoop (agent.go) │
|
|
||||||
│ - Message loop Run/Stop/Close │
|
|
||||||
│ - Dependency injection (agent_inject.go) │
|
|
||||||
│ - Message routing (agent_message.go) │
|
|
||||||
│ - Response publishing (agent_outbound.go) │
|
|
||||||
└─────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ Turn Coordinator (turn_coord.go) │
|
|
||||||
│ - runTurn(): main coordinator │
|
|
||||||
│ - abortTurn(): abort │
|
|
||||||
│ - askSideQuestion(): side question │
|
|
||||||
│ - selectCandidates(): model selection │
|
|
||||||
└─────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ Pipeline (pipeline_*.go) │
|
|
||||||
│ - SetupTurn(): initialization │
|
|
||||||
│ - CallLLM(): LLM call │
|
|
||||||
│ - ExecuteTools(): tool execution │
|
|
||||||
│ - Finalize(): finalization │
|
|
||||||
└─────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification Results
|
|
||||||
|
|
||||||
- ✅ `go build ./pkg/agent/...` - Pass
|
|
||||||
- ✅ `go vet ./pkg/agent/...` - No warnings
|
|
||||||
- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - Pass
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue