Merge upstream/main: resolve WeComWS and Weixin conflicts
This commit is contained in:
commit
1df8cfaabf
492 changed files with 76688 additions and 13686 deletions
8
.github/workflows/docker-build.yml
vendored
8
.github/workflows/docker-build.yml
vendored
|
|
@ -31,11 +31,11 @@ jobs:
|
||||||
|
|
||||||
# ── Docker Buildx ─────────────────────────
|
# ── Docker Buildx ─────────────────────────
|
||||||
- name: 🔧 Set up Docker Buildx
|
- name: 🔧 Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
# ── Login to GHCR ─────────────────────────
|
# ── Login to GHCR ─────────────────────────
|
||||||
- name: 🔑 Login to GitHub Container Registry
|
- name: 🔑 Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.GHCR_REGISTRY }}
|
registry: ${{ env.GHCR_REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|
@ -43,7 +43,7 @@ jobs:
|
||||||
|
|
||||||
# ── Login to Docker Hub ────────────────────
|
# ── Login to Docker Hub ────────────────────
|
||||||
- name: 🔑 Login to Docker Hub
|
- name: 🔑 Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
|
@ -62,7 +62,7 @@ jobs:
|
||||||
|
|
||||||
# ── Build & Push ──────────────────────────
|
# ── Build & Push ──────────────────────────
|
||||||
- name: 🚀 Build and push Docker image
|
- name: 🚀 Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
|
|
||||||
12
.github/workflows/nightly.yml
vendored
12
.github/workflows/nightly.yml
vendored
|
|
@ -48,7 +48,7 @@ jobs:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
|
|
@ -56,20 +56,20 @@ jobs:
|
||||||
run: corepack enable && corepack prepare pnpm@latest --activate
|
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
|
@ -79,7 +79,7 @@ jobs:
|
||||||
run: git tag "${{ steps.version.outputs.version }}"
|
run: git tag "${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
version: ~> v2
|
version: ~> v2
|
||||||
|
|
|
||||||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
|
|
@ -34,7 +34,7 @@ jobs:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
|
|
||||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
|
|
@ -66,7 +66,7 @@ jobs:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
|
|
@ -74,27 +74,27 @@ jobs:
|
||||||
run: corepack enable && corepack prepare pnpm@latest --activate
|
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
version: ~> v2
|
version: ~> v2
|
||||||
|
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -52,7 +52,14 @@ dist/
|
||||||
# Windows Application Icon/Resource
|
# Windows Application Icon/Resource
|
||||||
*.syso
|
*.syso
|
||||||
|
|
||||||
|
# Test telegram integration
|
||||||
|
cmd/telegram/
|
||||||
|
|
||||||
# Keep embedded backend dist directory placeholder in VCS
|
# Keep embedded backend dist directory placeholder in VCS
|
||||||
!web/backend/dist/
|
!web/backend/dist/
|
||||||
web/backend/dist/*
|
web/backend/dist/*
|
||||||
!web/backend/dist/.gitkeep
|
!web/backend/dist/.gitkeep
|
||||||
|
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
docker/data
|
||||||
|
|
|
||||||
73
Makefile
73
Makefile
|
|
@ -12,10 +12,11 @@ GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||||
BUILD_TIME=$(shell date +%FT%T%z)
|
BUILD_TIME=$(shell date +%FT%T%z)
|
||||||
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
||||||
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||||
LDFLAGS=-ldflags "-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w"
|
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
|
||||||
|
|
||||||
# Go variables
|
# Go variables
|
||||||
GO?=CGO_ENABLED=0 go
|
GO?=CGO_ENABLED=0 go
|
||||||
|
WEB_GO?=$(GO)
|
||||||
GOFLAGS?=-v -tags stdjson
|
GOFLAGS?=-v -tags stdjson
|
||||||
|
|
||||||
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
|
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
|
||||||
|
|
@ -79,6 +80,7 @@ ifeq ($(UNAME_S),Linux)
|
||||||
endif
|
endif
|
||||||
else ifeq ($(UNAME_S),Darwin)
|
else ifeq ($(UNAME_S),Darwin)
|
||||||
PLATFORM=darwin
|
PLATFORM=darwin
|
||||||
|
WEB_GO=CGO_ENABLED=1 go
|
||||||
ifeq ($(UNAME_M),x86_64)
|
ifeq ($(UNAME_M),x86_64)
|
||||||
ARCH=amd64
|
ARCH=amd64
|
||||||
else ifeq ($(UNAME_M),arm64)
|
else ifeq ($(UNAME_M),arm64)
|
||||||
|
|
@ -107,7 +109,7 @@ generate:
|
||||||
build: generate
|
build: generate
|
||||||
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
|
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
@$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||||
@echo "Build complete: $(BINARY_PATH)"
|
@echo "Build complete: $(BINARY_PATH)"
|
||||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
|
@ -119,7 +121,7 @@ build-launcher:
|
||||||
echo "Building frontend..."; \
|
echo "Building frontend..."; \
|
||||||
cd web/frontend && pnpm install && pnpm build:backend; \
|
cd web/frontend && pnpm install && pnpm build:backend; \
|
||||||
fi
|
fi
|
||||||
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
|
@$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
|
||||||
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
||||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
||||||
|
|
||||||
|
|
@ -128,16 +130,16 @@ build-whatsapp-native: generate
|
||||||
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||||
@echo "Building for multiple platforms..."
|
@echo "Building for multiple platforms..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||||
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
## @$(GO) build $(GOFLAGS) -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||||
@echo "Build complete"
|
@echo "Build complete"
|
||||||
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
|
@ -145,21 +147,21 @@ build-whatsapp-native: generate
|
||||||
build-linux-arm: generate
|
build-linux-arm: generate
|
||||||
@echo "Building for linux/arm (GOARM=7)..."
|
@echo "Building for linux/arm (GOARM=7)..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
|
||||||
|
|
||||||
## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
|
## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
|
||||||
build-linux-arm64: generate
|
build-linux-arm64: generate
|
||||||
@echo "Building for linux/arm64..."
|
@echo "Building for linux/arm64..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
||||||
|
|
||||||
## build-linux-mipsle: Build for Linux MIPS32 LE
|
## build-linux-mipsle: Build for Linux MIPS32 LE
|
||||||
build-linux-mipsle: generate
|
build-linux-mipsle: generate
|
||||||
@echo "Building for linux/mipsle (softfloat)..."
|
@echo "Building for linux/mipsle (softfloat)..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
$(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"
|
||||||
|
|
||||||
|
|
@ -171,18 +173,18 @@ build-pi-zero: build-linux-arm build-linux-arm64
|
||||||
build-all: generate
|
build-all: generate
|
||||||
@echo "Building for multiple platforms..."
|
@echo "Building for multiple platforms..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=loong64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=riscv64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
||||||
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
GOOS=darwin GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||||
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
GOOS=windows GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
GOOS=netbsd GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
|
GOOS=netbsd GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
|
||||||
GOOS=netbsd GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
|
GOOS=netbsd GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
|
||||||
@echo "All builds complete"
|
@echo "All builds complete"
|
||||||
|
|
||||||
## install: Install picoclaw to system and copy builtin skills
|
## install: Install picoclaw to system and copy builtin skills
|
||||||
|
|
@ -219,11 +221,14 @@ clean:
|
||||||
|
|
||||||
## vet: Run go vet for static analysis
|
## vet: Run go vet for static analysis
|
||||||
vet: generate
|
vet: generate
|
||||||
@$(GO) vet ./...
|
@packages="$$(go list ./...)" && \
|
||||||
|
$(GO) vet $$(printf '%s\n' "$$packages" | grep -v '^github.com/sipeed/picoclaw/web/')
|
||||||
|
@cd web/backend && $(WEB_GO) vet ./...
|
||||||
|
|
||||||
## test: Test Go code
|
## test: Test Go code
|
||||||
test: generate
|
test: generate
|
||||||
@$(GO) test ./...
|
@$(GO) test $$(go list ./... | grep -v github.com/sipeed/picoclaw/web/)
|
||||||
|
@cd web && make test
|
||||||
|
|
||||||
## fmt: Format Go code
|
## fmt: Format Go code
|
||||||
fmt:
|
fmt:
|
||||||
|
|
@ -292,6 +297,18 @@ docker-clean:
|
||||||
docker compose -f docker/docker-compose.full.yml down -v
|
docker compose -f docker/docker-compose.full.yml down -v
|
||||||
docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true
|
docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true
|
||||||
|
|
||||||
|
|
||||||
|
## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window)
|
||||||
|
build-macos-app:
|
||||||
|
@echo "Building macOS .app bundle..."
|
||||||
|
@if [ "$(UNAME_S)" != "Darwin" ]; then \
|
||||||
|
echo "Error: This target is only available on macOS"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@cd web && $(MAKE) build && cd ..
|
||||||
|
@./scripts/build-macos-app.sh $(BINARY_NAME)-$(PLATFORM)-$(ARCH)
|
||||||
|
@echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app"
|
||||||
|
|
||||||
## help: Show this help message
|
## help: Show this help message
|
||||||
help:
|
help:
|
||||||
@echo "picoclaw Makefile"
|
@echo "picoclaw Makefile"
|
||||||
|
|
|
||||||
1509
README.fr.md
1509
README.fr.md
File diff suppressed because it is too large
Load diff
579
README.id.md
Normal file
579
README.id.md
Normal file
|
|
@ -0,0 +1,579 @@
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
|
<h1>PicoClaw: Asisten AI Super Ringan berbasis Go</h1>
|
||||||
|
|
||||||
|
<h3>Perangkat Keras $10 · RAM 10MB · Boot ms · Let's Go, PicoClaw!</h3>
|
||||||
|
<p>
|
||||||
|
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
|
||||||
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
|
<br>
|
||||||
|
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||||
|
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||||
|
<br>
|
||||||
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
|
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||||
|
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [English](README.md) | **Bahasa Indonesia**
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **PicoClaw** adalah proyek open-source independen yang diinisiasi oleh [Sipeed](https://sipeed.com), ditulis sepenuhnya dalam **Go** — bukan fork dari OpenClaw, NanoBot, atau proyek lainnya.
|
||||||
|
|
||||||
|
**PicoClaw** adalah asisten AI pribadi yang super ringan, terinspirasi dari [NanoBot](https://github.com/HKUDS/nanobot). Dibangun ulang dari awal dalam **Go** melalui proses "self-bootstrapping" — AI Agent itu sendiri yang memandu migrasi arsitektur dan optimasi kode.
|
||||||
|
|
||||||
|
**Berjalan di perangkat keras $10 dengan RAM <10MB** — hemat 99% memori dibanding OpenClaw dan 98% lebih murah dari Mac mini!
|
||||||
|
|
||||||
|
<table align="center">
|
||||||
|
<tr align="center">
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/licheervnano.png" width="400" height="240">
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Peringatan Keamanan**
|
||||||
|
>
|
||||||
|
> * **TANPA KRIPTO:** PicoClaw **tidak** menerbitkan token atau cryptocurrency resmi apa pun. Semua klaim di `pump.fun` atau platform trading lainnya adalah **penipuan**.
|
||||||
|
> * **DOMAIN RESMI:** Satu-satunya website resmi adalah **[picoclaw.io](https://picoclaw.io)**, dan website perusahaan adalah **[sipeed.com](https://sipeed.com)**
|
||||||
|
> * **WASPADA:** Banyak domain `.ai/.org/.com/.net/...` telah didaftarkan oleh pihak ketiga. Jangan percaya mereka.
|
||||||
|
> * **CATATAN:** PicoClaw masih dalam tahap pengembangan awal yang cepat. Mungkin ada masalah keamanan yang belum terselesaikan. Jangan deploy ke produksi sebelum v1.0.
|
||||||
|
> * **CATATAN:** PicoClaw baru-baru ini menggabungkan banyak PR. Build terbaru mungkin menggunakan RAM 10-20MB. Optimasi sumber daya direncanakan setelah fitur stabil.
|
||||||
|
|
||||||
|
## 📢 Berita
|
||||||
|
|
||||||
|
2026-03-17 🚀 **v0.2.3 Dirilis!** UI system tray (Windows & Linux), pelacakan status sub-agent (`spawn_status`), eksperimental Gateway hot-reload, gerbang keamanan Cron, dan 2 perbaikan keamanan. PicoClaw telah mencapai **25K Stars**!
|
||||||
|
|
||||||
|
2026-03-09 🎉 **v0.2.1 — Update terbesar sejauh ini!** Dukungan protokol MCP, 4 channel baru (Matrix/IRC/WeCom/Discord Proxy), 3 provider baru (Kimi/Minimax/Avian), pipeline vision, penyimpanan memori JSONL, routing model.
|
||||||
|
|
||||||
|
2026-02-28 📦 **v0.2.0** dirilis dengan dukungan Docker Compose dan Web UI Launcher.
|
||||||
|
|
||||||
|
2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas kini aktif.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Berita sebelumnya...</summary>
|
||||||
|
|
||||||
|
2026-02-16 🎉 PicoClaw menembus 12K Stars dalam satu minggu! Peran maintainer komunitas dan [Roadmap](ROADMAP.md) resmi diluncurkan.
|
||||||
|
|
||||||
|
2026-02-13 🎉 PicoClaw menembus 5000 Stars dalam 4 hari! Roadmap proyek dan grup pengembang sedang dalam proses.
|
||||||
|
|
||||||
|
2026-02-09 🎉 **PicoClaw Diluncurkan!** Dibangun dalam 1 hari untuk menghadirkan AI Agent ke perangkat keras $10 dengan RAM <10MB. Let's Go, PicoClaw!
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## ✨ Fitur
|
||||||
|
|
||||||
|
🪶 **Super Ringan**: Penggunaan memori inti <10MB — 99% lebih kecil dari OpenClaw.*
|
||||||
|
|
||||||
|
💰 **Biaya Minimal**: Cukup efisien untuk berjalan di perangkat keras $10 — 98% lebih murah dari Mac mini.
|
||||||
|
|
||||||
|
⚡️ **Boot Secepat Kilat**: Startup 400x lebih cepat. Boot dalam <1 detik bahkan di prosesor single-core 0,6GHz.
|
||||||
|
|
||||||
|
🌍 **Portabilitas Sejati**: Satu binary untuk RISC-V, ARM, MIPS, dan x86. Satu binary, jalan di mana saja!
|
||||||
|
|
||||||
|
🤖 **AI-Bootstrapped**: Implementasi Go native murni — 95% kode inti dihasilkan oleh Agent dengan penyempurnaan human-in-the-loop.
|
||||||
|
|
||||||
|
🔌 **Dukungan MCP**: Integrasi [Model Context Protocol](https://modelcontextprotocol.io/) native — hubungkan server MCP mana pun untuk memperluas kapabilitas Agent.
|
||||||
|
|
||||||
|
👁️ **Pipeline Vision**: Kirim gambar dan file langsung ke Agent — encoding base64 otomatis untuk LLM multimodal.
|
||||||
|
|
||||||
|
🧠 **Routing Cerdas**: Routing model berbasis aturan — kueri sederhana diarahkan ke model ringan, menghemat biaya API.
|
||||||
|
|
||||||
|
_*Build terbaru mungkin menggunakan 10-20MB karena penggabungan PR yang cepat. Optimasi sumber daya direncanakan. Perbandingan kecepatan boot berdasarkan benchmark single-core 0,8GHz (lihat tabel di bawah)._
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||||
|
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||||
|
| **Bahasa** | TypeScript | Python | **Go** |
|
||||||
|
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||||
|
| **Waktu Boot**</br>(core 0,8GHz) | >500d | >30d | **<1d** |
|
||||||
|
| **Biaya** | Mac Mini $599 | Kebanyakan board Linux ~$50 | **Board Linux mana pun**</br>**mulai $10** |
|
||||||
|
|
||||||
|
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
> **[Daftar Kompatibilitas Hardware](docs/hardware-compatibility.md)** — Lihat semua board yang telah diuji, dari RISC-V $5 hingga Raspberry Pi hingga ponsel Android. Board Anda belum terdaftar? Kirim PR!
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## 🦾 Demonstrasi
|
||||||
|
|
||||||
|
### 🛠️ Alur Kerja Asisten Standar
|
||||||
|
|
||||||
|
<table align="center">
|
||||||
|
<tr align="center">
|
||||||
|
<th><p align="center">Mode Full-Stack Engineer</p></th>
|
||||||
|
<th><p align="center">Pencatatan & Perencanaan</p></th>
|
||||||
|
<th><p align="center">Pencarian Web & Pembelajaran</p></th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||||
|
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||||
|
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">Develop · Deploy · Scale</td>
|
||||||
|
<td align="center">Jadwal · Otomasi · Ingat</td>
|
||||||
|
<td align="center">Temukan · Wawasan · Tren</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### 🐜 Deploy Inovatif dengan Footprint Rendah
|
||||||
|
|
||||||
|
PicoClaw dapat di-deploy di hampir semua perangkat Linux!
|
||||||
|
|
||||||
|
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versi E(Ethernet) atau W(WiFi6), untuk home assistant minimal
|
||||||
|
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), atau $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), untuk operasi server otomatis
|
||||||
|
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) atau $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), untuk pengawasan cerdas
|
||||||
|
|
||||||
|
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||||
|
|
||||||
|
🌟 Lebih Banyak Kasus Deploy Menanti!
|
||||||
|
|
||||||
|
## 📦 Instalasi
|
||||||
|
|
||||||
|
### Unduh dari picoclaw.io (Direkomendasikan)
|
||||||
|
|
||||||
|
Kunjungi **[picoclaw.io](https://picoclaw.io)** — website resmi mendeteksi platform Anda secara otomatis dan menyediakan unduhan satu klik. Tidak perlu memilih arsitektur secara manual.
|
||||||
|
|
||||||
|
### Unduh binary yang sudah dikompilasi
|
||||||
|
|
||||||
|
Atau, unduh binary untuk platform Anda dari halaman [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
|
||||||
|
|
||||||
|
### Build dari source (untuk pengembangan)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
||||||
|
cd picoclaw
|
||||||
|
make deps
|
||||||
|
|
||||||
|
# Build binary inti
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Build Web UI Launcher (diperlukan untuk mode WebUI)
|
||||||
|
make build-launcher
|
||||||
|
|
||||||
|
# Build untuk berbagai platform
|
||||||
|
make build-all
|
||||||
|
|
||||||
|
# Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||||
|
make build-pi-zero
|
||||||
|
|
||||||
|
# Build dan instal
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
**Raspberry Pi Zero 2 W:** Gunakan binary yang sesuai dengan OS Anda: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk build keduanya.
|
||||||
|
|
||||||
|
## 🚀 Panduan Memulai Cepat
|
||||||
|
|
||||||
|
### 🌐 WebUI Launcher (Direkomendasikan untuk Desktop)
|
||||||
|
|
||||||
|
WebUI Launcher menyediakan antarmuka berbasis browser untuk konfigurasi dan chat. Ini adalah cara termudah untuk memulai — tidak perlu pengetahuan command-line.
|
||||||
|
|
||||||
|
**Opsi 1: Klik dua kali (Desktop)**
|
||||||
|
|
||||||
|
Setelah mengunduh dari [picoclaw.io](https://picoclaw.io), klik dua kali `picoclaw-launcher` (atau `picoclaw-launcher.exe` di Windows). Browser Anda akan terbuka otomatis di `http://localhost:18800`.
|
||||||
|
|
||||||
|
**Opsi 2: Command line**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw-launcher
|
||||||
|
# Buka http://localhost:18800 di browser Anda
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **Akses jarak jauh / Docker / VM:** Tambahkan flag `-public` untuk mendengarkan di semua antarmuka:
|
||||||
|
> ```bash
|
||||||
|
> picoclaw-launcher -public
|
||||||
|
> ```
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Memulai:**
|
||||||
|
|
||||||
|
Buka WebUI, lalu: **1)** Konfigurasi Provider (tambahkan API key LLM Anda) -> **2)** Konfigurasi Channel (mis. Telegram) -> **3)** Mulai Gateway -> **4)** Chat!
|
||||||
|
|
||||||
|
Untuk dokumentasi WebUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Docker (alternatif)</b></summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone repo ini
|
||||||
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
cd picoclaw
|
||||||
|
|
||||||
|
# 2. Jalankan pertama kali — otomatis membuat docker/data/config.json lalu keluar
|
||||||
|
# (hanya terpicu ketika config.json dan workspace/ keduanya tidak ada)
|
||||||
|
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||||
|
# Container mencetak "First-run setup complete." dan berhenti.
|
||||||
|
|
||||||
|
# 3. Atur API key Anda
|
||||||
|
vim docker/data/config.json
|
||||||
|
|
||||||
|
# 4. Mulai
|
||||||
|
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||||
|
# Buka http://localhost:18800
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Pengguna Docker / VM:** Gateway mendengarkan di `127.0.0.1` secara default. Atur `PICOCLAW_GATEWAY_HOST=0.0.0.0` atau gunakan flag `-public` agar dapat diakses dari host.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cek log
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f
|
||||||
|
|
||||||
|
# Hentikan
|
||||||
|
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||||
|
|
||||||
|
# Update
|
||||||
|
docker compose -f docker/docker-compose.yml pull
|
||||||
|
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 💻 TUI Launcher (Direkomendasikan untuk Headless / SSH)
|
||||||
|
|
||||||
|
TUI (Terminal UI) Launcher menyediakan antarmuka terminal lengkap untuk konfigurasi dan manajemen. Ideal untuk server, Raspberry Pi, dan lingkungan headless lainnya.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw-launcher-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Memulai:**
|
||||||
|
|
||||||
|
Gunakan menu TUI untuk: **1)** Konfigurasi Provider -> **2)** Konfigurasi Channel -> **3)** Mulai Gateway -> **4)** Chat!
|
||||||
|
|
||||||
|
Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||||
|
|
||||||
|
### 📱 Android
|
||||||
|
|
||||||
|
Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw.
|
||||||
|
|
||||||
|
**Opsi 1: Termux (tersedia sekarang)**
|
||||||
|
|
||||||
|
1. Instal [Termux](https://github.com/termux/termux-app) (unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play)
|
||||||
|
2. Jalankan perintah berikut:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unduh rilis terbaru
|
||||||
|
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
|
||||||
|
tar xzf picoclaw_Linux_arm64.tar.gz
|
||||||
|
pkg install proot
|
||||||
|
termux-chroot ./picoclaw onboard # chroot menyediakan tata letak filesystem Linux standar
|
||||||
|
```
|
||||||
|
|
||||||
|
Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi.
|
||||||
|
|
||||||
|
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||||
|
|
||||||
|
**Opsi 2: Instal APK (segera hadir)**
|
||||||
|
|
||||||
|
APK Android mandiri dengan WebUI bawaan sedang dalam pengembangan. Pantau terus!
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Terminal Launcher (untuk lingkungan dengan sumber daya terbatas)</b></summary>
|
||||||
|
|
||||||
|
Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON.
|
||||||
|
|
||||||
|
**1. Inisialisasi**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw onboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Ini membuat `~/.picoclaw/config.json` dan direktori workspace.
|
||||||
|
|
||||||
|
**2. Konfigurasi** (`~/.picoclaw/config.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model_name": "gpt-5.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Lihat `config/config.example.json` di repo untuk template konfigurasi lengkap dengan semua opsi yang tersedia.
|
||||||
|
|
||||||
|
**3. Chat**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pertanyaan satu kali
|
||||||
|
picoclaw agent -m "What is 2+2?"
|
||||||
|
|
||||||
|
# Mode interaktif
|
||||||
|
picoclaw agent
|
||||||
|
|
||||||
|
# Mulai gateway untuk integrasi aplikasi chat
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 🔌 Providers (LLM)
|
||||||
|
|
||||||
|
PicoClaw mendukung 30+ provider LLM melalui konfigurasi `model_list`. Gunakan format `protocol/model`:
|
||||||
|
|
||||||
|
| Provider | Protocol | API Key | Catatan |
|
||||||
|
|----------|----------|---------|---------|
|
||||||
|
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Diperlukan | GPT-5.4, GPT-4o, o3, dll. |
|
||||||
|
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Diperlukan | Claude Opus 4.6, Sonnet 4.6, dll. |
|
||||||
|
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Diperlukan | Gemini 3 Flash, 2.5 Pro, dll. |
|
||||||
|
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Diperlukan | 200+ model, API terpadu |
|
||||||
|
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Diperlukan | GLM-4.7, GLM-5, dll. |
|
||||||
|
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Diperlukan | DeepSeek-V3, DeepSeek-R1 |
|
||||||
|
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Diperlukan | Doubao, model Ark |
|
||||||
|
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Diperlukan | Qwen3, Qwen-Max, dll. |
|
||||||
|
| [Groq](https://console.groq.com/keys) | `groq/` | Diperlukan | Inferensi cepat (Llama, Mixtral) |
|
||||||
|
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Diperlukan | Model Kimi |
|
||||||
|
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Diperlukan | Model MiniMax |
|
||||||
|
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Diperlukan | Mistral Large, Codestral |
|
||||||
|
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Diperlukan | Model yang di-host NVIDIA |
|
||||||
|
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Diperlukan | Inferensi cepat |
|
||||||
|
| [Novita AI](https://novita.ai/) | `novita/` | Diperlukan | Berbagai model open |
|
||||||
|
| [Ollama](https://ollama.com/) | `ollama/` | Tidak perlu | Model lokal, self-hosted |
|
||||||
|
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Tidak perlu | Deploy lokal, kompatibel OpenAI |
|
||||||
|
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Bervariasi | Proxy untuk 100+ provider |
|
||||||
|
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Diperlukan | Deploy Azure enterprise |
|
||||||
|
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login dengan device code |
|
||||||
|
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Deploy lokal (Ollama, vLLM, dll.)</b></summary>
|
||||||
|
|
||||||
|
**Ollama:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "local-llama",
|
||||||
|
"model": "ollama/llama3.1:8b",
|
||||||
|
"api_base": "http://localhost:11434/v1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**vLLM:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "local-vllm",
|
||||||
|
"model": "vllm/your-model",
|
||||||
|
"api_base": "http://localhost:8000/v1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](docs/providers.md).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 💬 Channels (Aplikasi Chat)
|
||||||
|
|
||||||
|
Bicara dengan PicoClaw Anda melalui 17+ platform pesan:
|
||||||
|
|
||||||
|
| Channel | Pengaturan | Protocol | Dokumentasi |
|
||||||
|
|---------|------------|----------|-------------|
|
||||||
|
| **Telegram** | Mudah (bot token) | Long polling | [Panduan](docs/channels/telegram/README.md) |
|
||||||
|
| **Discord** | Mudah (bot token + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) |
|
||||||
|
| **WhatsApp** | Mudah (scan QR atau bridge URL) | Native / Bridge | [Panduan](docs/chat-apps.md#whatsapp) |
|
||||||
|
| **Weixin** | Mudah (scan QR native) | iLink API | [Panduan](docs/chat-apps.md#weixin) |
|
||||||
|
| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) |
|
||||||
|
| **Slack** | Mudah (bot + app token) | Socket Mode | [Panduan](docs/channels/slack/README.md) |
|
||||||
|
| **Matrix** | Sedang (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) |
|
||||||
|
| **DingTalk** | Sedang (client credentials) | Stream | [Panduan](docs/channels/dingtalk/README.md) |
|
||||||
|
| **Feishu / Lark** | Sedang (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) |
|
||||||
|
| **LINE** | Sedang (credentials + webhook) | Webhook | [Panduan](docs/channels/line/README.md) |
|
||||||
|
| **WeCom Bot** | Sedang (webhook URL) | Webhook | [Panduan](docs/channels/wecom/wecom_bot/README.md) |
|
||||||
|
| **WeCom App** | Sedang (corp credentials) | Webhook | [Panduan](docs/channels/wecom/wecom_app/README.md) |
|
||||||
|
| **WeCom AI Bot** | Sedang (token + AES key) | WebSocket / Webhook | [Panduan](docs/channels/wecom/wecom_aibot/README.md) |
|
||||||
|
| **IRC** | Sedang (server + nick) | IRC protocol | [Panduan](docs/chat-apps.md#irc) |
|
||||||
|
| **OneBot** | Sedang (WebSocket URL) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) |
|
||||||
|
| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) |
|
||||||
|
| **Pico** | Mudah (aktifkan) | Native protocol | Bawaan |
|
||||||
|
| **Pico Client** | Mudah (WebSocket URL) | WebSocket | Bawaan |
|
||||||
|
|
||||||
|
> Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama.
|
||||||
|
|
||||||
|
Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md).
|
||||||
|
|
||||||
|
## 🔧 Tools
|
||||||
|
|
||||||
|
### 🔍 Pencarian Web
|
||||||
|
|
||||||
|
PicoClaw dapat mencari web untuk memberikan informasi terkini. Konfigurasi di `tools.web`:
|
||||||
|
|
||||||
|
| Mesin Pencari | API Key | Tier Gratis | Tautan |
|
||||||
|
|--------------|---------|-------------|--------|
|
||||||
|
| DuckDuckGo | Tidak perlu | Tidak terbatas | Fallback bawaan |
|
||||||
|
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 kueri/hari | Bertenaga AI, dioptimalkan untuk bahasa Mandarin |
|
||||||
|
| [Tavily](https://tavily.com) | Diperlukan | 1000 kueri/bulan | Dioptimalkan untuk AI Agent |
|
||||||
|
| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 kueri/bulan | Cepat dan privat |
|
||||||
|
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Pencarian bertenaga AI |
|
||||||
|
| [SearXNG](https://github.com/searxng/searxng) | Tidak perlu | Self-hosted | Mesin metasearch gratis |
|
||||||
|
| [GLM Search](https://open.bigmodel.cn/) | Diperlukan | Bervariasi | Pencarian web Zhipu |
|
||||||
|
|
||||||
|
### ⚙️ Tools Lainnya
|
||||||
|
|
||||||
|
PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](docs/tools_configuration.md) untuk detail.
|
||||||
|
|
||||||
|
## 🎯 Skills
|
||||||
|
|
||||||
|
Skills adalah kapabilitas modular yang memperluas Agent Anda. Dimuat dari file `SKILL.md` di workspace Anda.
|
||||||
|
|
||||||
|
**Instal skills dari ClawHub:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw skills search "web scraping"
|
||||||
|
picoclaw skills install <skill-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Konfigurasi token ClawHub** (opsional, untuk rate limit lebih tinggi):
|
||||||
|
|
||||||
|
Tambahkan ke `config.json` Anda:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"skills": {
|
||||||
|
"registries": {
|
||||||
|
"clawhub": {
|
||||||
|
"auth_token": "your-clawhub-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](docs/tools_configuration.md#skills-tool).
|
||||||
|
|
||||||
|
## 🔗 MCP (Model Context Protocol)
|
||||||
|
|
||||||
|
PicoClaw mendukung [MCP](https://modelcontextprotocol.io/) secara native — hubungkan server MCP mana pun untuk memperluas kapabilitas Agent Anda dengan tools dan sumber data eksternal.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"mcp": {
|
||||||
|
"enabled": true,
|
||||||
|
"servers": {
|
||||||
|
"filesystem": {
|
||||||
|
"enabled": true,
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Untuk konfigurasi MCP lengkap (transport stdio, SSE, HTTP, Tool Discovery), lihat [Konfigurasi Tools - MCP](docs/tools_configuration.md#mcp-tool).
|
||||||
|
|
||||||
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Bergabung dengan Jaringan Sosial Agent
|
||||||
|
|
||||||
|
Hubungkan PicoClaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi mana pun.
|
||||||
|
|
||||||
|
**Baca `https://clawdchat.ai/skill.md` dan ikuti instruksi untuk bergabung dengan [ClawdChat.ai](https://clawdchat.ai)**
|
||||||
|
|
||||||
|
## 🖥️ Referensi CLI
|
||||||
|
|
||||||
|
| Perintah | Deskripsi |
|
||||||
|
| -------------------------- | -------------------------------- |
|
||||||
|
| `picoclaw onboard` | Inisialisasi konfigurasi & workspace |
|
||||||
|
| `picoclaw onboard weixin` | Hubungkan akun WeChat via QR |
|
||||||
|
| `picoclaw agent -m "..."` | Chat dengan agent |
|
||||||
|
| `picoclaw agent` | Mode chat interaktif |
|
||||||
|
| `picoclaw gateway` | Mulai gateway |
|
||||||
|
| `picoclaw status` | Tampilkan status |
|
||||||
|
| `picoclaw version` | Tampilkan info versi |
|
||||||
|
| `picoclaw model` | Lihat atau ganti model default |
|
||||||
|
| `picoclaw cron list` | Daftar semua tugas terjadwal |
|
||||||
|
| `picoclaw cron add ...` | Tambah tugas terjadwal |
|
||||||
|
| `picoclaw cron disable` | Nonaktifkan tugas terjadwal |
|
||||||
|
| `picoclaw cron remove` | Hapus tugas terjadwal |
|
||||||
|
| `picoclaw skills list` | Daftar skill yang terinstal |
|
||||||
|
| `picoclaw skills install` | Instal skill |
|
||||||
|
| `picoclaw migrate` | Migrasi data dari versi lama |
|
||||||
|
| `picoclaw auth login` | Autentikasi dengan provider |
|
||||||
|
|
||||||
|
### ⏰ Tugas Terjadwal / Pengingat
|
||||||
|
|
||||||
|
PicoClaw mendukung pengingat terjadwal dan tugas berulang melalui tool `cron`:
|
||||||
|
|
||||||
|
* **Pengingat satu kali**: "Ingatkan saya dalam 10 menit" -> terpicu sekali setelah 10 menit
|
||||||
|
* **Tugas berulang**: "Ingatkan saya setiap 2 jam" -> terpicu setiap 2 jam
|
||||||
|
* **Ekspresi cron**: "Ingatkan saya jam 9 pagi setiap hari" -> menggunakan ekspresi cron
|
||||||
|
|
||||||
|
## 📚 Dokumentasi
|
||||||
|
|
||||||
|
Untuk panduan lengkap di luar README ini:
|
||||||
|
|
||||||
|
| Topik | Deskripsi |
|
||||||
|
|-------|-----------|
|
||||||
|
| [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent |
|
||||||
|
| [Aplikasi Chat](docs/chat-apps.md) | Semua 17+ panduan pengaturan channel |
|
||||||
|
| [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sandbox keamanan |
|
||||||
|
| [Providers & Models](docs/providers.md) | 30+ provider LLM, routing model, konfigurasi model_list |
|
||||||
|
| [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async |
|
||||||
|
| [Hooks](docs/hooks/README.md) | Sistem hook berbasis event: observer, interceptor, approval hook |
|
||||||
|
| [Steering](docs/steering.md) | Menyuntikkan pesan ke dalam loop agent yang sedang berjalan |
|
||||||
|
| [SubTurn](docs/subturn.md) | Koordinasi subagent, kontrol konkurensi, siklus hidup |
|
||||||
|
| [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya |
|
||||||
|
| [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan per-tool, kebijakan exec, MCP, Skills |
|
||||||
|
| [Kompatibilitas Hardware](docs/hardware-compatibility.md) | Board yang telah diuji, persyaratan minimum |
|
||||||
|
|
||||||
|
## 🤝 Kontribusi & Roadmap
|
||||||
|
|
||||||
|
PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca.
|
||||||
|
|
||||||
|
Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](CONTRIBUTING.md) untuk panduan.
|
||||||
|
|
||||||
|
Grup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge!
|
||||||
|
|
||||||
|
Grup Pengguna:
|
||||||
|
|
||||||
|
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
|
WeChat:
|
||||||
|
<img src="assets/wechat.png" alt="Kode QR grup WeChat" width="512">
|
||||||
|
|
||||||
578
README.it.md
Normal file
578
README.it.md
Normal file
|
|
@ -0,0 +1,578 @@
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
|
<h1>PicoClaw: Assistente IA Ultra-Efficiente in Go</h1>
|
||||||
|
|
||||||
|
<h3>Hardware da $10 · 10MB di RAM · Avvio in ms · Let's Go, PicoClaw!</h3>
|
||||||
|
<p>
|
||||||
|
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
|
||||||
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
|
<br>
|
||||||
|
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||||
|
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||||
|
<br>
|
||||||
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
|
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||||
|
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [English](README.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **PicoClaw** è un progetto open-source indipendente avviato da [Sipeed](https://sipeed.com), scritto interamente in **Go** da zero — non è un fork di OpenClaw, NanoBot o di qualsiasi altro progetto.
|
||||||
|
|
||||||
|
**PicoClaw** è un assistente IA personale ultra-leggero ispirato a [NanoBot](https://github.com/HKUDS/nanobot). È stato riscritto da zero in **Go** attraverso un processo di "auto-bootstrapping" — l'Agent IA stesso ha guidato la migrazione architetturale e l'ottimizzazione del codice.
|
||||||
|
|
||||||
|
**Funziona su hardware da $10 con <10MB di RAM** — il 99% di memoria in meno rispetto a OpenClaw e il 98% più economico di un Mac mini!
|
||||||
|
|
||||||
|
<table align="center">
|
||||||
|
<tr align="center">
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/licheervnano.png" width="400" height="240">
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Avviso di Sicurezza**
|
||||||
|
>
|
||||||
|
> * **NESSUNA CRYPTO:** PicoClaw **non** ha emesso token o criptovalute ufficiali. Qualsiasi annuncio su `pump.fun` o altre piattaforme di trading è una **truffa**.
|
||||||
|
> * **DOMINIO UFFICIALE:** L'**UNICO** sito ufficiale è **[picoclaw.io](https://picoclaw.io)**, e il sito aziendale è **[sipeed.com](https://sipeed.com)**
|
||||||
|
> * **ATTENZIONE:** Molti domini `.ai/.org/.com/.net/...` sono stati registrati da terze parti. Non fidarti di essi.
|
||||||
|
> * **NOTA:** PicoClaw è in fase di sviluppo iniziale rapido. Potrebbero esserci problemi di sicurezza non risolti. Non distribuire in produzione prima della v1.0.
|
||||||
|
> * **NOTA:** PicoClaw ha recentemente unito molte PR. Le build recenti potrebbero usare 10-20MB di RAM. L'ottimizzazione delle risorse è pianificata dopo la stabilizzazione delle funzionalità.
|
||||||
|
|
||||||
|
## 📢 Novità
|
||||||
|
|
||||||
|
2026-03-17 🚀 **v0.2.3 rilasciata!** Interfaccia system tray (Windows & Linux), query sullo stato dei sub-agent (`spawn_status`), hot-reload sperimentale del Gateway, gate di sicurezza per Cron e 2 correzioni di sicurezza. PicoClaw raggiunge **25K Stars**!
|
||||||
|
|
||||||
|
2026-03-09 🎉 **v0.2.1 — Il più grande aggiornamento di sempre!** Supporto al protocollo MCP, 4 nuovi canali (Matrix/IRC/WeCom/Discord Proxy), 3 nuovi provider (Kimi/Minimax/Avian), pipeline di visione, store di memoria JSONL e routing dei modelli.
|
||||||
|
|
||||||
|
2026-02-28 📦 **v0.2.0** rilasciata con supporto Docker Compose e Web UI Launcher.
|
||||||
|
|
||||||
|
2026-02-26 🎉 PicoClaw raggiunge **20K stelle** in soli 17 giorni! Orchestrazione automatica dei canali e interfacce di capacità sono attive.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Notizie precedenti...</summary>
|
||||||
|
|
||||||
|
2026-02-16 🎉 PicoClaw supera 12K stelle in una settimana! Ruoli di maintainer della community e [Roadmap](ROADMAP.md) pubblicati ufficialmente.
|
||||||
|
|
||||||
|
2026-02-13 🎉 PicoClaw supera 5000 stelle in 4 giorni! Roadmap del progetto e gruppi sviluppatori in fase di avvio.
|
||||||
|
|
||||||
|
2026-02-09 🎉 **PicoClaw lanciato!** Costruito in 1 giorno per portare gli AI Agent su hardware da $10 con <10MB di RAM. Let's Go, PicoClaw!
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## ✨ Caratteristiche
|
||||||
|
|
||||||
|
🪶 **Ultra-Leggero**: Impronta di memoria <10MB — il 99% più piccolo rispetto a OpenClaw.*
|
||||||
|
|
||||||
|
💰 **Costo Minimo**: Abbastanza efficiente da girare su hardware da $10 — il 98% più economico di un Mac mini.
|
||||||
|
|
||||||
|
⚡️ **Avvio Fulmineo**: Avvio 400 volte più veloce. Boot in meno di 1 secondo anche su un singolo core a 0,6 GHz.
|
||||||
|
|
||||||
|
🌍 **Vera Portabilità**: Singolo binario per RISC-V, ARM, MIPS e x86. Un binario, funziona ovunque!
|
||||||
|
|
||||||
|
🤖 **Auto-Costruito dall'IA**: Implementazione nativa in Go — il 95% del codice core è stato generato da un Agent e perfezionato tramite revisione umana nel ciclo.
|
||||||
|
|
||||||
|
🔌 **Supporto MCP**: Integrazione nativa del [Model Context Protocol](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità dell'Agent.
|
||||||
|
|
||||||
|
👁️ **Pipeline di Visione**: Invia immagini e file direttamente all'Agent — codifica base64 automatica per LLM multimodali.
|
||||||
|
|
||||||
|
🧠 **Routing Intelligente**: Routing dei modelli basato su regole — le query semplici vanno verso modelli leggeri, risparmiando sui costi API.
|
||||||
|
|
||||||
|
_*Le build recenti potrebbero usare 10-20MB a causa delle fusioni rapide di PR. L'ottimizzazione delle risorse è pianificata. Il confronto dell'avvio è basato su benchmark con singolo core a 0,8 GHz (vedi tabella sotto)._
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||||
|
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||||
|
| **Linguaggio** | TypeScript | Python | **Go** |
|
||||||
|
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||||
|
| **Avvio**</br>(core 0,8 GHz) | >500s | >30s | **<1s** |
|
||||||
|
| **Costo** | Mac Mini $599 | La maggior parte degli SBC Linux ~$50 | **Qualsiasi scheda Linux**</br>**a partire da $10** |
|
||||||
|
|
||||||
|
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
> **[Lista di Compatibilità Hardware](docs/hardware-compatibility.md)** — Vedi tutte le schede testate, dai $5 RISC-V al Raspberry Pi ai telefoni Android. La tua scheda non è elencata? Invia una PR!
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## 🦾 Dimostrazione
|
||||||
|
|
||||||
|
### 🛠️ Flussi di Lavoro Standard dell'Assistente
|
||||||
|
|
||||||
|
<table align="center">
|
||||||
|
<tr align="center">
|
||||||
|
<th><p align="center">Modalità Ingegnere Full-Stack</p></th>
|
||||||
|
<th><p align="center">Log & Pianificazione</p></th>
|
||||||
|
<th><p align="center">Ricerca Web & Apprendimento</p></th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||||
|
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||||
|
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">Sviluppa · Distribuisci · Scala</td>
|
||||||
|
<td align="center">Pianifica · Automatizza · Memorizza</td>
|
||||||
|
<td align="center">Scopri · Analizza · Tendenze</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### 🐜 Deploy Innovativo a Bassa Impronta
|
||||||
|
|
||||||
|
PicoClaw può essere distribuito su quasi qualsiasi dispositivo Linux!
|
||||||
|
|
||||||
|
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versione E (Ethernet) o W (WiFi6), per un assistente domotico minimale
|
||||||
|
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), o $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), per la manutenzione automatizzata dei server
|
||||||
|
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) o $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), per la sorveglianza intelligente
|
||||||
|
|
||||||
|
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||||
|
|
||||||
|
🌟 Molti altri scenari di deploy ti aspettano!
|
||||||
|
|
||||||
|
## 📦 Installazione
|
||||||
|
|
||||||
|
### Scarica da picoclaw.io (Consigliato)
|
||||||
|
|
||||||
|
Visita **[picoclaw.io](https://picoclaw.io)** — il sito ufficiale rileva automaticamente la tua piattaforma e fornisce il download con un clic. Non è necessario scegliere manualmente l'architettura.
|
||||||
|
|
||||||
|
### Scarica il binario precompilato
|
||||||
|
|
||||||
|
In alternativa, scarica il binario per la tua piattaforma dalla pagina delle [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
|
||||||
|
|
||||||
|
### Compila dai sorgenti (per lo sviluppo)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
||||||
|
cd picoclaw
|
||||||
|
make deps
|
||||||
|
|
||||||
|
# Compila il binario core
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Compila il Web UI Launcher (necessario per la modalità WebUI)
|
||||||
|
make build-launcher
|
||||||
|
|
||||||
|
# Compila per più piattaforme
|
||||||
|
make build-all
|
||||||
|
|
||||||
|
# Compila per Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||||
|
make build-pi-zero
|
||||||
|
|
||||||
|
# Compila e installa
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
**Raspberry Pi Zero 2 W:** Usa il binario che corrisponde al tuo OS: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Oppure esegui `make build-pi-zero` per compilare entrambi.
|
||||||
|
|
||||||
|
## 🚀 Guida Rapida
|
||||||
|
|
||||||
|
### 🌐 WebUI Launcher (Consigliato per Desktop)
|
||||||
|
|
||||||
|
Il WebUI Launcher fornisce un'interfaccia basata su browser per la configurazione e la chat. È il modo più semplice per iniziare — non è richiesta alcuna conoscenza della riga di comando.
|
||||||
|
|
||||||
|
**Opzione 1: Doppio clic (Desktop)**
|
||||||
|
|
||||||
|
Dopo aver scaricato da [picoclaw.io](https://picoclaw.io), fai doppio clic su `picoclaw-launcher` (o `picoclaw-launcher.exe` su Windows). Il browser si aprirà automaticamente su `http://localhost:18800`.
|
||||||
|
|
||||||
|
**Opzione 2: Riga di comando**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw-launcher
|
||||||
|
# Apri http://localhost:18800 nel browser
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **Accesso remoto / Docker / VM:** Aggiungi il flag `-public` per ascoltare su tutte le interfacce:
|
||||||
|
> ```bash
|
||||||
|
> picoclaw-launcher -public
|
||||||
|
> ```
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Per iniziare:**
|
||||||
|
|
||||||
|
Apri il WebUI, poi: **1)** Configura un Provider (aggiungi la tua API key LLM) -> **2)** Configura un Channel (es. Telegram) -> **3)** Avvia il Gateway -> **4)** Chatta!
|
||||||
|
|
||||||
|
Per la documentazione dettagliata del WebUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Docker (alternativa)</b></summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clona questo repo
|
||||||
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
cd picoclaw
|
||||||
|
|
||||||
|
# 2. Prima esecuzione — genera automaticamente docker/data/config.json poi si ferma
|
||||||
|
# (si attiva solo quando sia config.json che workspace/ sono assenti)
|
||||||
|
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||||
|
# Il container stampa "First-run setup complete." e si ferma.
|
||||||
|
|
||||||
|
# 3. Imposta le tue API key
|
||||||
|
vim docker/data/config.json
|
||||||
|
|
||||||
|
# 4. Avvia
|
||||||
|
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||||
|
# Apri http://localhost:18800
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Utenti Docker / VM:** Il Gateway ascolta su `127.0.0.1` per impostazione predefinita. Imposta `PICOCLAW_GATEWAY_HOST=0.0.0.0` o usa il flag `-public` per renderlo accessibile dall'host.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Controlla i log
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f
|
||||||
|
|
||||||
|
# Ferma
|
||||||
|
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||||
|
|
||||||
|
# Aggiorna
|
||||||
|
docker compose -f docker/docker-compose.yml pull
|
||||||
|
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 💻 TUI Launcher (Consigliato per Headless / SSH)
|
||||||
|
|
||||||
|
Il TUI (Terminal UI) Launcher fornisce un'interfaccia terminale completa per la configurazione e la gestione. Ideale per server, Raspberry Pi e altri ambienti headless.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw-launcher-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Per iniziare:**
|
||||||
|
|
||||||
|
Usa i menu TUI per: **1)** Configurare un Provider -> **2)** Configurare un Channel -> **3)** Avviare il Gateway -> **4)** Chattare!
|
||||||
|
|
||||||
|
Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||||
|
|
||||||
|
### 📱 Android
|
||||||
|
|
||||||
|
Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw.
|
||||||
|
|
||||||
|
**Opzione 1: Termux (disponibile ora)**
|
||||||
|
|
||||||
|
1. Installa [Termux](https://github.com/termux/termux-app) (scarica da [GitHub Releases](https://github.com/termux/termux-app/releases), o cerca su F-Droid / Google Play)
|
||||||
|
2. Esegui i seguenti comandi:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scarica l'ultima release
|
||||||
|
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
|
||||||
|
tar xzf picoclaw_Linux_arm64.tar.gz
|
||||||
|
pkg install proot
|
||||||
|
termux-chroot ./picoclaw onboard # chroot fornisce un layout standard del filesystem Linux
|
||||||
|
```
|
||||||
|
|
||||||
|
Poi segui la sezione Terminal Launcher qui sotto per completare la configurazione.
|
||||||
|
|
||||||
|
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||||
|
|
||||||
|
**Opzione 2: APK Install (prossimamente)**
|
||||||
|
|
||||||
|
Un APK Android standalone con WebUI integrato è in sviluppo. Resta sintonizzato!
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Terminal Launcher (per ambienti con risorse limitate)</b></summary>
|
||||||
|
|
||||||
|
Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON.
|
||||||
|
|
||||||
|
**1. Inizializza**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw onboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Questo crea `~/.picoclaw/config.json` e la directory workspace.
|
||||||
|
|
||||||
|
**2. Configura** (`~/.picoclaw/config.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model_name": "gpt-5.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Vedi `config/config.example.json` nel repo per un template di configurazione completo con tutte le opzioni disponibili.
|
||||||
|
|
||||||
|
**3. Chatta**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Domanda singola
|
||||||
|
picoclaw agent -m "Quanto fa 2+2?"
|
||||||
|
|
||||||
|
# Modalità interattiva
|
||||||
|
picoclaw agent
|
||||||
|
|
||||||
|
# Avvia il gateway per l'integrazione con app di chat
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 🔌 Provider (LLM)
|
||||||
|
|
||||||
|
PicoClaw supporta 30+ provider LLM tramite la configurazione `model_list`. Usa il formato `protocollo/modello`:
|
||||||
|
|
||||||
|
| Provider | Protocollo | API Key | Note |
|
||||||
|
|----------|------------|---------|------|
|
||||||
|
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Richiesta | GPT-5.4, GPT-4o, o3, ecc. |
|
||||||
|
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Richiesta | Claude Opus 4.6, Sonnet 4.6, ecc. |
|
||||||
|
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Richiesta | Gemini 3 Flash, 2.5 Pro, ecc. |
|
||||||
|
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Richiesta | 200+ modelli, API unificata |
|
||||||
|
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Richiesta | GLM-4.7, GLM-5, ecc. |
|
||||||
|
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Richiesta | DeepSeek-V3, DeepSeek-R1 |
|
||||||
|
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Richiesta | Doubao, modelli Ark |
|
||||||
|
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Richiesta | Qwen3, Qwen-Max, ecc. |
|
||||||
|
| [Groq](https://console.groq.com/keys) | `groq/` | Richiesta | Inferenza veloce (Llama, Mixtral) |
|
||||||
|
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Richiesta | Modelli Kimi |
|
||||||
|
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Richiesta | Modelli MiniMax |
|
||||||
|
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Richiesta | Mistral Large, Codestral |
|
||||||
|
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Richiesta | Modelli ospitati NVIDIA |
|
||||||
|
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Richiesta | Inferenza veloce |
|
||||||
|
| [Novita AI](https://novita.ai/) | `novita/` | Richiesta | Vari modelli open |
|
||||||
|
| [Ollama](https://ollama.com/) | `ollama/` | Non necessaria | Modelli locali, self-hosted |
|
||||||
|
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Non necessaria | Deploy locale, compatibile OpenAI |
|
||||||
|
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Variabile | Proxy per 100+ provider |
|
||||||
|
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Richiesta | Deploy Azure enterprise |
|
||||||
|
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login con device code |
|
||||||
|
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Deploy locale (Ollama, vLLM, ecc.)</b></summary>
|
||||||
|
|
||||||
|
**Ollama:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "local-llama",
|
||||||
|
"model": "ollama/llama3.1:8b",
|
||||||
|
"api_base": "http://localhost:11434/v1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**vLLM:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "local-vllm",
|
||||||
|
"model": "vllm/your-model",
|
||||||
|
"api_base": "http://localhost:8000/v1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](docs/providers.md).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 💬 Channel (App di Chat)
|
||||||
|
|
||||||
|
Parla con il tuo PicoClaw attraverso 17+ piattaforme di messaggistica:
|
||||||
|
|
||||||
|
| Channel | Configurazione | Protocollo | Docs |
|
||||||
|
|---------|----------------|------------|------|
|
||||||
|
| **Telegram** | Facile (bot token) | Long polling | [Guida](docs/channels/telegram/README.md) |
|
||||||
|
| **Discord** | Facile (bot token + intents) | WebSocket | [Guida](docs/channels/discord/README.md) |
|
||||||
|
| **WhatsApp** | Facile (QR scan o bridge URL) | Nativo / Bridge | [Guida](docs/chat-apps.md#whatsapp) |
|
||||||
|
| **Weixin** | Facile (scan QR nativo) | iLink API | [Guida](docs/chat-apps.md#weixin) |
|
||||||
|
| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guida](docs/channels/qq/README.md) |
|
||||||
|
| **Slack** | Facile (bot + app token) | Socket Mode | [Guida](docs/channels/slack/README.md) |
|
||||||
|
| **Matrix** | Medio (homeserver + token) | Sync API | [Guida](docs/channels/matrix/README.md) |
|
||||||
|
| **DingTalk** | Medio (credenziali client) | Stream | [Guida](docs/channels/dingtalk/README.md) |
|
||||||
|
| **Feishu / Lark** | Medio (App ID + Secret) | WebSocket/SDK | [Guida](docs/channels/feishu/README.md) |
|
||||||
|
| **LINE** | Medio (credenziali + webhook) | Webhook | [Guida](docs/channels/line/README.md) |
|
||||||
|
| **WeCom Bot** | Medio (webhook URL) | Webhook | [Guida](docs/channels/wecom/wecom_bot/README.md) |
|
||||||
|
| **WeCom App** | Medio (credenziali aziendali) | Webhook | [Guida](docs/channels/wecom/wecom_app/README.md) |
|
||||||
|
| **WeCom AI Bot** | Medio (token + AES key) | WebSocket / Webhook | [Guida](docs/channels/wecom/wecom_aibot/README.md) |
|
||||||
|
| **IRC** | Medio (server + nick) | Protocollo IRC | [Guida](docs/chat-apps.md#irc) |
|
||||||
|
| **OneBot** | Medio (WebSocket URL) | OneBot v11 | [Guida](docs/channels/onebot/README.md) |
|
||||||
|
| **MaixCam** | Facile (abilita) | TCP socket | [Guida](docs/channels/maixcam/README.md) |
|
||||||
|
| **Pico** | Facile (abilita) | Protocollo nativo | Integrato |
|
||||||
|
| **Pico Client** | Facile (WebSocket URL) | WebSocket | Integrato |
|
||||||
|
|
||||||
|
> Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso.
|
||||||
|
|
||||||
|
Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md).
|
||||||
|
|
||||||
|
## 🔧 Strumenti
|
||||||
|
|
||||||
|
### 🔍 Ricerca Web
|
||||||
|
|
||||||
|
PicoClaw può cercare sul web per fornire informazioni aggiornate. Configura in `tools.web`:
|
||||||
|
|
||||||
|
| Motore di Ricerca | API Key | Piano Gratuito | Link |
|
||||||
|
|-------------------|---------|----------------|------|
|
||||||
|
| DuckDuckGo | Non necessaria | Illimitato | Fallback integrato |
|
||||||
|
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Richiesta | 1000 query/giorno | IA, ottimizzato per il cinese |
|
||||||
|
| [Tavily](https://tavily.com) | Richiesta | 1000 query/mese | Ottimizzato per AI Agent |
|
||||||
|
| [Brave Search](https://brave.com/search/api) | Richiesta | 2000 query/mese | Veloce e privato |
|
||||||
|
| [Perplexity](https://www.perplexity.ai) | Richiesta | A pagamento | Ricerca potenziata dall'IA |
|
||||||
|
| [SearXNG](https://github.com/searxng/searxng) | Non necessaria | Self-hosted | Metasearch engine gratuito |
|
||||||
|
| [GLM Search](https://open.bigmodel.cn/) | Richiesta | Variabile | Ricerca web Zhipu |
|
||||||
|
|
||||||
|
### ⚙️ Altri Strumenti
|
||||||
|
|
||||||
|
PicoClaw include strumenti integrati per operazioni su file, esecuzione di codice, pianificazione e altro. Vedi [Configurazione degli Strumenti](docs/tools_configuration.md) per i dettagli.
|
||||||
|
|
||||||
|
## 🎯 Skill
|
||||||
|
|
||||||
|
Le Skill sono capacità modulari che estendono il tuo Agent. Vengono caricate dai file `SKILL.md` nel tuo workspace.
|
||||||
|
|
||||||
|
**Installa skill da ClawHub:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw skills search "web scraping"
|
||||||
|
picoclaw skills install <skill-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configura il token ClawHub** (opzionale, per limiti di frequenza più alti):
|
||||||
|
|
||||||
|
Aggiungi al tuo `config.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"skills": {
|
||||||
|
"registries": {
|
||||||
|
"clawhub": {
|
||||||
|
"auth_token": "your-clawhub-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](docs/tools_configuration.md#skills-tool).
|
||||||
|
|
||||||
|
## 🔗 MCP (Model Context Protocol)
|
||||||
|
|
||||||
|
PicoClaw supporta nativamente [MCP](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità del tuo Agent con strumenti e sorgenti di dati esterni.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"mcp": {
|
||||||
|
"enabled": true,
|
||||||
|
"servers": {
|
||||||
|
"filesystem": {
|
||||||
|
"enabled": true,
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](docs/tools_configuration.md#mcp-tool).
|
||||||
|
|
||||||
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Unisciti al Social Network degli Agent
|
||||||
|
|
||||||
|
Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singolo messaggio tramite CLI o qualsiasi app di chat integrata.
|
||||||
|
|
||||||
|
**Leggi `https://clawdchat.ai/skill.md` e segui le istruzioni per unirti a [ClawdChat.ai](https://clawdchat.ai)**
|
||||||
|
|
||||||
|
## 🖥️ Riferimento CLI
|
||||||
|
|
||||||
|
| Comando | Descrizione |
|
||||||
|
| ------------------------- | ---------------------------------- |
|
||||||
|
| `picoclaw onboard` | Inizializza config & workspace |
|
||||||
|
| `picoclaw onboard weixin` | Connetti account WeChat tramite QR |
|
||||||
|
| `picoclaw agent -m "..."` | Chatta con l'agent |
|
||||||
|
| `picoclaw agent` | Modalità chat interattiva |
|
||||||
|
| `picoclaw gateway` | Avvia il gateway |
|
||||||
|
| `picoclaw status` | Mostra lo stato |
|
||||||
|
| `picoclaw version` | Mostra le info sulla versione |
|
||||||
|
| `picoclaw model` | Visualizza o cambia il modello predefinito |
|
||||||
|
| `picoclaw cron list` | Elenca tutti i job pianificati |
|
||||||
|
| `picoclaw cron add ...` | Aggiunge un job pianificato |
|
||||||
|
| `picoclaw cron disable` | Disabilita un job pianificato |
|
||||||
|
| `picoclaw cron remove` | Rimuove un job pianificato |
|
||||||
|
| `picoclaw skills list` | Elenca le skill installate |
|
||||||
|
| `picoclaw skills install` | Installa una skill |
|
||||||
|
| `picoclaw migrate` | Migra i dati dalle versioni precedenti |
|
||||||
|
| `picoclaw auth login` | Autenticazione con i provider |
|
||||||
|
|
||||||
|
### ⏰ Task Pianificati / Promemoria
|
||||||
|
|
||||||
|
PicoClaw supporta promemoria pianificati e task ricorrenti tramite lo strumento `cron`:
|
||||||
|
|
||||||
|
* **Promemoria una tantum**: "Ricordami tra 10 minuti" -> si attiva una volta dopo 10 min
|
||||||
|
* **Task ricorrenti**: "Ricordami ogni 2 ore" -> si attiva ogni 2 ore
|
||||||
|
* **Espressioni cron**: "Ricordami alle 9 ogni giorno" -> usa un'espressione cron
|
||||||
|
|
||||||
|
## 📚 Documentazione
|
||||||
|
|
||||||
|
Per guide dettagliate oltre questo README:
|
||||||
|
|
||||||
|
| Argomento | Descrizione |
|
||||||
|
|-----------|-------------|
|
||||||
|
| [Docker & Avvio Rapido](docs/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent |
|
||||||
|
| [App di Chat](docs/chat-apps.md) | Tutte le guide di configurazione per 17+ channel |
|
||||||
|
| [Configurazione](docs/configuration.md) | Variabili d'ambiente, struttura del workspace, sandbox di sicurezza |
|
||||||
|
| [Provider & Modelli](docs/providers.md) | 30+ provider LLM, routing dei modelli, configurazione model_list |
|
||||||
|
| [Spawn & Task Asincroni](docs/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent |
|
||||||
|
| [Hooks](docs/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook |
|
||||||
|
| [Steering](docs/steering.md) | Iniettare messaggi in un loop agent in esecuzione |
|
||||||
|
| [SubTurn](docs/subturn.md) | Coordinamento subagent, controllo concorrenza, ciclo di vita |
|
||||||
|
| [Risoluzione Problemi](docs/troubleshooting.md) | Problemi comuni e soluzioni |
|
||||||
|
| [Configurazione degli Strumenti](docs/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec, MCP, Skill |
|
||||||
|
| [Compatibilità Hardware](docs/hardware-compatibility.md) | Schede testate, requisiti minimi |
|
||||||
|
|
||||||
|
## 🤝 Contribuisci & Roadmap
|
||||||
|
|
||||||
|
Le PR sono benvenute! Il codice è volutamente piccolo e leggibile.
|
||||||
|
|
||||||
|
Consulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](CONTRIBUTING.md) per le linee guida.
|
||||||
|
|
||||||
|
Gruppo sviluppatori in costruzione, unisciti dopo la tua prima PR accettata!
|
||||||
|
|
||||||
|
Gruppi utenti:
|
||||||
|
|
||||||
|
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
|
WeChat:
|
||||||
|
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||||
1423
README.ja.md
1423
README.ja.md
File diff suppressed because it is too large
Load diff
1458
README.pt-br.md
1458
README.pt-br.md
File diff suppressed because it is too large
Load diff
1444
README.vi.md
1444
README.vi.md
File diff suppressed because it is too large
Load diff
1086
README.zh.md
1086
README.zh.md
File diff suppressed because it is too large
Load diff
BIN
assets/hardware-banner.jpg
Normal file
BIN
assets/hardware-banner.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
BIN
assets/launcher-tui.jpg
Normal file
BIN
assets/launcher-tui.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
BIN
assets/launcher-webui.jpg
Normal file
BIN
assets/launcher-webui.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 61 KiB |
236
cmd/picoclaw-launcher-tui/config/config.go
Normal file
236
cmd/picoclaw-launcher-tui/config/config.go
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
// Package config provides types and I/O for ~/.picoclaw/tui.toml.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultConfigPath returns the default path to the tui.toml config file.
|
||||||
|
func DefaultConfigPath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
home = "."
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".picoclaw", "tui.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml.
|
||||||
|
type TUIConfig struct {
|
||||||
|
Version string `toml:"version"`
|
||||||
|
Model Model `toml:"model"`
|
||||||
|
Provider Provider `toml:"provider"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
Type string `toml:"type"` // "provider" (default) | "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
Schemes []Scheme `toml:"schemes"`
|
||||||
|
Users []User `toml:"users"`
|
||||||
|
Current ProviderCurrent `toml:"current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Scheme struct {
|
||||||
|
Name string `toml:"name"` // unique key
|
||||||
|
BaseURL string `toml:"baseURL"` // required
|
||||||
|
Type string `toml:"type"` // "openai-compatible" (default) | "anthropic"
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique
|
||||||
|
Type string `toml:"type"` // "key" (default) | "OAuth"
|
||||||
|
Key string `toml:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderCurrent struct {
|
||||||
|
Scheme string `toml:"scheme"` // references Scheme.Name
|
||||||
|
User string `toml:"user"` // references User.Name where User.Scheme == Scheme
|
||||||
|
Model string `toml:"model"` // from GET <baseURL>/models
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a minimal valid TUIConfig.
|
||||||
|
func DefaultConfig() *TUIConfig {
|
||||||
|
return &TUIConfig{
|
||||||
|
Version: "1.0",
|
||||||
|
Model: Model{Type: "provider"},
|
||||||
|
Provider: Provider{
|
||||||
|
Schemes: []Scheme{},
|
||||||
|
Users: []User{},
|
||||||
|
Current: ProviderCurrent{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads the TUI config from path. Returns a default config if the file does not exist.
|
||||||
|
func Load(path string) (*TUIConfig, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return DefaultConfig(), nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read config file %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if _, err := toml.Decode(string(data), cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDefaults(cfg)
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes cfg to path atomically (safe for flash / SD storage).
|
||||||
|
func Save(path string, cfg *TUIConfig) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create config directory: %w", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc := toml.NewEncoder(&buf)
|
||||||
|
if err := enc.Encode(cfg); err != nil {
|
||||||
|
return fmt.Errorf("failed to encode config: %w", err)
|
||||||
|
}
|
||||||
|
if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write config file %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDefaults(cfg *TUIConfig) {
|
||||||
|
if cfg.Version == "" {
|
||||||
|
cfg.Version = "1.0"
|
||||||
|
}
|
||||||
|
if cfg.Model.Type == "" {
|
||||||
|
cfg.Model.Type = "provider"
|
||||||
|
}
|
||||||
|
for i := range cfg.Provider.Schemes {
|
||||||
|
if cfg.Provider.Schemes[i].Type == "" {
|
||||||
|
cfg.Provider.Schemes[i].Type = "openai-compatible"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range cfg.Provider.Users {
|
||||||
|
if cfg.Provider.Users[i].Type == "" {
|
||||||
|
cfg.Provider.Users[i].Type = "key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemeByName returns the first Scheme whose Name matches, or nil.
|
||||||
|
func (p *Provider) SchemeByName(name string) *Scheme {
|
||||||
|
for i := range p.Schemes {
|
||||||
|
if p.Schemes[i].Name == name {
|
||||||
|
return &p.Schemes[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsersForScheme returns all users whose Scheme field matches schemeName.
|
||||||
|
func (p *Provider) UsersForScheme(schemeName string) []User {
|
||||||
|
var out []User
|
||||||
|
for _, u := range p.Users {
|
||||||
|
if u.Scheme == schemeName {
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json
|
||||||
|
// Adds/replaces a "tui-prefer" model entry and sets it as the default model.
|
||||||
|
// Preserves all other existing fields in the config file unchanged.
|
||||||
|
func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
home = "."
|
||||||
|
}
|
||||||
|
mainConfigPath := filepath.Join(home, ".picoclaw", "config.json")
|
||||||
|
|
||||||
|
var cfg map[string]any
|
||||||
|
if data, readErr := os.ReadFile(mainConfigPath); readErr == nil {
|
||||||
|
if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil {
|
||||||
|
cfg = make(map[string]any)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cfg = make(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := cfg["agents"]; !ok {
|
||||||
|
cfg["agents"] = make(map[string]any)
|
||||||
|
}
|
||||||
|
agents, ok := cfg["agents"].(map[string]any)
|
||||||
|
if ok {
|
||||||
|
if _, ok := agents["defaults"]; !ok {
|
||||||
|
agents["defaults"] = make(map[string]any)
|
||||||
|
}
|
||||||
|
defaults, ok := agents["defaults"].(map[string]any)
|
||||||
|
if ok {
|
||||||
|
defaults["model"] = "tui-prefer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tuiModel := map[string]any{
|
||||||
|
"model_name": "tui-prefer",
|
||||||
|
"model": modelID,
|
||||||
|
"api_key": user.Key,
|
||||||
|
"api_base": scheme.BaseURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
modelList := []any{}
|
||||||
|
if ml, ok := cfg["model_list"].([]any); ok {
|
||||||
|
modelList = ml
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i, m := range modelList {
|
||||||
|
if entry, ok := m.(map[string]any); ok {
|
||||||
|
if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" {
|
||||||
|
modelList[i] = tuiModel
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
modelList = append(modelList, tuiModel)
|
||||||
|
}
|
||||||
|
cfg["model_list"] = modelList
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(mainConfigPath, data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *TUIConfig) CurrentModelLabel() string {
|
||||||
|
cur := cfg.Provider.Current
|
||||||
|
if cur.Model == "" {
|
||||||
|
return "(not configured)"
|
||||||
|
}
|
||||||
|
label := cur.Scheme
|
||||||
|
if label != "" {
|
||||||
|
label += " / "
|
||||||
|
}
|
||||||
|
return label + cur.Model
|
||||||
|
}
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
package configstore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
configDirName = ".picoclaw"
|
|
||||||
configFileName = "config.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ConfigPath() (string, error) {
|
|
||||||
dir, err := ConfigDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Join(dir, configFileName), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ConfigDir() (string, error) {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Join(home, configDirName), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Load() (*picoclawconfig.Config, error) {
|
|
||||||
path, err := ConfigPath()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return picoclawconfig.LoadConfig(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Save(cfg *picoclawconfig.Config) error {
|
|
||||||
if cfg == nil {
|
|
||||||
return errors.New("config is nil")
|
|
||||||
}
|
|
||||||
path, err := ConfigPath()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return picoclawconfig.SaveConfig(path, cfg)
|
|
||||||
}
|
|
||||||
|
|
@ -1,522 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
|
||||||
"github.com/rivo/tview"
|
|
||||||
|
|
||||||
configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config"
|
|
||||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
type appState struct {
|
|
||||||
app *tview.Application
|
|
||||||
pages *tview.Pages
|
|
||||||
stack []string
|
|
||||||
config *picoclawconfig.Config
|
|
||||||
configPath string
|
|
||||||
gatewayCmd *exec.Cmd
|
|
||||||
menus map[string]*Menu
|
|
||||||
original []byte
|
|
||||||
hasOriginal bool
|
|
||||||
backupPath string
|
|
||||||
dirty bool
|
|
||||||
logPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func Run() error {
|
|
||||||
applyStyles()
|
|
||||||
cfg, err := configstore.Load()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
path, err := configstore.ConfigPath()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg == nil {
|
|
||||||
cfg = picoclawconfig.DefaultConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
originalData, hasOriginal := loadOriginalConfig(path)
|
|
||||||
backupPath := path + ".bak"
|
|
||||||
if hasOriginal {
|
|
||||||
_ = writeBackupConfig(backupPath, originalData)
|
|
||||||
}
|
|
||||||
|
|
||||||
logPath := filepath.Join(filepath.Dir(path), "gateway.log")
|
|
||||||
state := &appState{
|
|
||||||
app: tview.NewApplication(),
|
|
||||||
pages: tview.NewPages(),
|
|
||||||
config: cfg,
|
|
||||||
configPath: path,
|
|
||||||
menus: map[string]*Menu{},
|
|
||||||
original: originalData,
|
|
||||||
hasOriginal: hasOriginal,
|
|
||||||
backupPath: backupPath,
|
|
||||||
logPath: logPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
state.push("main", state.mainMenu())
|
|
||||||
|
|
||||||
root := tview.NewFlex().SetDirection(tview.FlexRow)
|
|
||||||
root.AddItem(bannerView(), 6, 0, false)
|
|
||||||
root.AddItem(state.pages, 0, 1, true)
|
|
||||||
root.AddItem(footerView(), 1, 0, false)
|
|
||||||
|
|
||||||
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) push(name string, primitive tview.Primitive) {
|
|
||||||
s.pages.AddPage(name, primitive, true, true)
|
|
||||||
s.stack = append(s.stack, name)
|
|
||||||
s.pages.SwitchToPage(name)
|
|
||||||
if menu, ok := primitive.(*Menu); ok {
|
|
||||||
s.menus[name] = menu
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) pop() {
|
|
||||||
if len(s.stack) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
last := s.stack[len(s.stack)-1]
|
|
||||||
s.pages.RemovePage(last)
|
|
||||||
s.stack = s.stack[:len(s.stack)-1]
|
|
||||||
if len(s.stack) == 0 {
|
|
||||||
s.app.Stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
current := s.stack[len(s.stack)-1]
|
|
||||||
s.pages.SwitchToPage(current)
|
|
||||||
if menu, ok := s.menus[current]; ok {
|
|
||||||
s.refreshMenu(current, menu)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) mainMenu() tview.Primitive {
|
|
||||||
menu := NewMenu("Menu", nil)
|
|
||||||
refreshMainMenu(menu, s)
|
|
||||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
||||||
switch event.Key() {
|
|
||||||
case tcell.KeyEsc:
|
|
||||||
s.requestExit()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return event
|
|
||||||
})
|
|
||||||
|
|
||||||
return menu
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) refreshMenu(name string, menu *Menu) {
|
|
||||||
switch name {
|
|
||||||
case "main":
|
|
||||||
refreshMainMenu(menu, s)
|
|
||||||
case "model":
|
|
||||||
refreshModelMenuFromState(menu, s)
|
|
||||||
case "channel":
|
|
||||||
refreshChannelMenuFromState(menu, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) countChannels() (enabled int, total int) {
|
|
||||||
c := s.config.Channels
|
|
||||||
entries := []bool{
|
|
||||||
c.Telegram.Enabled,
|
|
||||||
c.Discord.Enabled,
|
|
||||||
c.QQ.Enabled,
|
|
||||||
c.MaixCam.Enabled,
|
|
||||||
c.WhatsApp.Enabled,
|
|
||||||
c.Feishu.Enabled,
|
|
||||||
c.DingTalk.Enabled,
|
|
||||||
c.Slack.Enabled,
|
|
||||||
c.Matrix.Enabled,
|
|
||||||
c.LINE.Enabled,
|
|
||||||
c.OneBot.Enabled,
|
|
||||||
c.WeCom.Enabled,
|
|
||||||
c.WeComApp.Enabled,
|
|
||||||
}
|
|
||||||
total = len(entries)
|
|
||||||
for _, v := range entries {
|
|
||||||
if v {
|
|
||||||
enabled++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return enabled, total
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshMainMenuIfPresent(s *appState) {
|
|
||||||
if menu, ok := s.menus["main"]; ok {
|
|
||||||
refreshMainMenu(menu, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshMainMenu(menu *Menu, s *appState) {
|
|
||||||
selectedModel := s.selectedModelName()
|
|
||||||
modelReady := selectedModel != ""
|
|
||||||
channelReady := s.hasEnabledChannel()
|
|
||||||
enabledCount, totalChannels := s.countChannels()
|
|
||||||
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
|
|
||||||
|
|
||||||
gatewayLabel := "Start Gateway"
|
|
||||||
gatewayDescription := "Launch gateway for channels"
|
|
||||||
if gatewayRunning {
|
|
||||||
gatewayLabel = "Stop Gateway"
|
|
||||||
gatewayDescription = "Gateway running"
|
|
||||||
}
|
|
||||||
|
|
||||||
items := []MenuItem{
|
|
||||||
{
|
|
||||||
Label: rootModelLabel(selectedModel),
|
|
||||||
Description: rootModelDescription(),
|
|
||||||
Action: func() {
|
|
||||||
s.push("model", s.modelMenu())
|
|
||||||
},
|
|
||||||
MainColor: func() *tcell.Color {
|
|
||||||
if modelReady {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
color := tcell.ColorGray
|
|
||||||
return &color
|
|
||||||
}(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Label: rootChannelLabel(channelReady),
|
|
||||||
Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
|
|
||||||
Action: func() {
|
|
||||||
s.push("channel", s.channelMenu())
|
|
||||||
},
|
|
||||||
MainColor: func() *tcell.Color {
|
|
||||||
if channelReady {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
color := tcell.ColorGray
|
|
||||||
return &color
|
|
||||||
}(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Label: "Start Talk",
|
|
||||||
Description: "Open picoclaw agent in terminal",
|
|
||||||
Action: func() {
|
|
||||||
s.requestStartTalk()
|
|
||||||
},
|
|
||||||
Disabled: !modelReady,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Label: gatewayLabel,
|
|
||||||
Description: gatewayDescription,
|
|
||||||
Action: func() {
|
|
||||||
if gatewayRunning {
|
|
||||||
s.stopGateway()
|
|
||||||
} else {
|
|
||||||
s.requestStartGateway()
|
|
||||||
}
|
|
||||||
refreshMainMenu(menu, s)
|
|
||||||
},
|
|
||||||
Disabled: !gatewayRunning && (!modelReady || !channelReady),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Label: "View Gateway Log",
|
|
||||||
Description: "Open gateway.log",
|
|
||||||
Action: func() {
|
|
||||||
s.viewGatewayLog()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Label: "Exit",
|
|
||||||
Description: "Exit the TUI",
|
|
||||||
Action: func() {
|
|
||||||
s.requestExit()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
menu.applyItems(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) applyChangesValidated() bool {
|
|
||||||
if err := s.config.ValidateModelList(); err != nil {
|
|
||||||
s.showMessage("Validation failed", err.Error())
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if err := s.validateAgentModel(); err != nil {
|
|
||||||
s.showMessage("Validation failed", err.Error())
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if err := configstore.Save(s.config); err != nil {
|
|
||||||
s.showMessage("Save failed", err.Error())
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if data, err := os.ReadFile(s.configPath); err == nil {
|
|
||||||
s.original = data
|
|
||||||
s.hasOriginal = true
|
|
||||||
_ = writeBackupConfig(s.backupPath, data)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) requestExit() {
|
|
||||||
if s.dirty {
|
|
||||||
s.confirmApplyOrDiscard(func() {
|
|
||||||
s.app.Stop()
|
|
||||||
}, func() {
|
|
||||||
s.discardChanges()
|
|
||||||
s.app.Stop()
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.app.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) requestStartTalk() {
|
|
||||||
if s.dirty {
|
|
||||||
s.confirmApplyOrDiscard(func() {
|
|
||||||
s.startTalk()
|
|
||||||
}, func() {
|
|
||||||
s.startTalk()
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.startTalk()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) requestStartGateway() {
|
|
||||||
if s.dirty {
|
|
||||||
s.confirmApplyOrDiscard(func() {
|
|
||||||
s.startGateway()
|
|
||||||
}, func() {
|
|
||||||
s.startGateway()
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.startGateway()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) viewGatewayLog() {
|
|
||||||
data, err := os.ReadFile(s.logPath)
|
|
||||||
if err != nil {
|
|
||||||
s.showMessage("Log not found", "gateway.log not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
text := tview.NewTextView()
|
|
||||||
text.SetBorder(true).SetTitle("Gateway Log")
|
|
||||||
text.SetText(string(data))
|
|
||||||
text.SetDoneFunc(func(key tcell.Key) {
|
|
||||||
s.pages.RemovePage("log")
|
|
||||||
})
|
|
||||||
text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
||||||
if event.Key() == tcell.KeyEsc {
|
|
||||||
s.pages.RemovePage("log")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return event
|
|
||||||
})
|
|
||||||
s.pages.AddPage("log", text, true, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) selectedModelName() string {
|
|
||||||
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
|
||||||
if modelName == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if !s.isActiveModelValid() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return modelName
|
|
||||||
}
|
|
||||||
|
|
||||||
func rootModelLabel(selected string) string {
|
|
||||||
if selected == "" {
|
|
||||||
return "Model (None)"
|
|
||||||
}
|
|
||||||
return "Model (" + selected + ")"
|
|
||||||
}
|
|
||||||
|
|
||||||
func rootModelDescription() string {
|
|
||||||
return "Using SPACE to choose your model"
|
|
||||||
}
|
|
||||||
|
|
||||||
func rootChannelLabel(valid bool) string {
|
|
||||||
if !valid {
|
|
||||||
return "Channel (no channel enabled)"
|
|
||||||
}
|
|
||||||
return "Channel"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) startTalk() {
|
|
||||||
if !s.isActiveModelValid() {
|
|
||||||
s.showMessage("Model required", "Select a valid model before starting talk")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.applyChangesValidated() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.app.Suspend(func() {
|
|
||||||
cmd := exec.Command("picoclaw", "agent")
|
|
||||||
cmd.Stdin = os.Stdin
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
_ = cmd.Run()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) startGateway() {
|
|
||||||
if !s.isActiveModelValid() {
|
|
||||||
s.showMessage("Model required", "Select a valid model before starting gateway")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.hasEnabledChannel() {
|
|
||||||
s.showMessage("Channel required", "Enable at least one channel before starting gateway")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.applyChangesValidated() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = stopGatewayProcess()
|
|
||||||
cmd := exec.Command("picoclaw", "gateway")
|
|
||||||
logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
s.showMessage("Gateway failed", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cmd.Stdout = logFile
|
|
||||||
cmd.Stderr = logFile
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
s.showMessage("Gateway failed", err.Error())
|
|
||||||
_ = logFile.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = logFile.Close()
|
|
||||||
s.gatewayCmd = cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) stopGateway() {
|
|
||||||
_ = stopGatewayProcess()
|
|
||||||
if s.gatewayCmd != nil && s.gatewayCmd.Process != nil {
|
|
||||||
_ = s.gatewayCmd.Process.Kill()
|
|
||||||
}
|
|
||||||
s.gatewayCmd = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) isGatewayRunning() bool {
|
|
||||||
return isGatewayProcessRunning()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) validateAgentModel() error {
|
|
||||||
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
|
||||||
if modelName == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err := s.config.GetModelConfig(modelName)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) isActiveModelValid() bool {
|
|
||||||
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
|
||||||
if modelName == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
cfg, err := s.config.GetModelConfig(modelName)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth"
|
|
||||||
hasModel := strings.TrimSpace(cfg.Model) != ""
|
|
||||||
return hasKey && hasModel
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) hasEnabledChannel() bool {
|
|
||||||
c := s.config.Channels
|
|
||||||
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
|
|
||||||
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
|
|
||||||
c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {
|
|
||||||
if s.pages.HasPage("apply") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modal := tview.NewModal().
|
|
||||||
SetText("Apply changes or discard before continuing?").
|
|
||||||
AddButtons([]string{"Cancel", "Discard", "Apply"}).
|
|
||||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
|
||||||
s.pages.RemovePage("apply")
|
|
||||||
switch buttonLabel {
|
|
||||||
case "Discard":
|
|
||||||
s.discardChanges()
|
|
||||||
if onDiscard != nil {
|
|
||||||
onDiscard()
|
|
||||||
}
|
|
||||||
case "Apply":
|
|
||||||
if s.applyChangesValidated() {
|
|
||||||
s.dirty = false
|
|
||||||
if onApply != nil {
|
|
||||||
onApply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
modal.SetBorder(true)
|
|
||||||
s.pages.AddPage("apply", modal, true, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) discardChanges() {
|
|
||||||
if s.hasOriginal {
|
|
||||||
_ = writeOriginalConfig(s.configPath, s.original)
|
|
||||||
} else {
|
|
||||||
_ = os.Remove(s.configPath)
|
|
||||||
}
|
|
||||||
_ = os.Remove(s.backupPath)
|
|
||||||
if cfg, err := configstore.Load(); err == nil && cfg != nil {
|
|
||||||
s.config = cfg
|
|
||||||
}
|
|
||||||
s.dirty = false
|
|
||||||
refreshMainMenuIfPresent(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) showMessage(title, message string) {
|
|
||||||
if s.pages.HasPage("message") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modal := tview.NewModal().
|
|
||||||
SetText(strings.TrimSpace(message)).
|
|
||||||
AddButtons([]string{"OK"}).
|
|
||||||
SetDoneFunc(func(_ int, _ string) {
|
|
||||||
s.pages.RemovePage("message")
|
|
||||||
})
|
|
||||||
modal.SetTitle(title).SetBorder(true)
|
|
||||||
modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
|
|
||||||
modal.SetTextColor(tview.Styles.PrimaryTextColor)
|
|
||||||
modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255))
|
|
||||||
modal.SetButtonTextColor(tview.Styles.PrimaryTextColor)
|
|
||||||
s.pages.AddPage("message", modal, true, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadOriginalConfig(path string) ([]byte, bool) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return data, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeOriginalConfig(path string, data []byte) error {
|
|
||||||
return os.WriteFile(path, data, 0o600)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeBackupConfig(path string, data []byte) error {
|
|
||||||
return os.WriteFile(path, data, 0o600)
|
|
||||||
}
|
|
||||||
|
|
@ -1,433 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
|
||||||
"github.com/rivo/tview"
|
|
||||||
|
|
||||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *appState) buildChannelMenuItems() []MenuItem {
|
|
||||||
return []MenuItem{
|
|
||||||
channelItem(
|
|
||||||
"Telegram",
|
|
||||||
"Telegram bot settings",
|
|
||||||
s.config.Channels.Telegram.Enabled,
|
|
||||||
func() { s.push("channel-telegram", s.telegramForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"Discord",
|
|
||||||
"Discord bot settings",
|
|
||||||
s.config.Channels.Discord.Enabled,
|
|
||||||
func() { s.push("channel-discord", s.discordForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"QQ",
|
|
||||||
"QQ bot settings",
|
|
||||||
s.config.Channels.QQ.Enabled,
|
|
||||||
func() { s.push("channel-qq", s.qqForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"MaixCam",
|
|
||||||
"MaixCam gateway",
|
|
||||||
s.config.Channels.MaixCam.Enabled,
|
|
||||||
func() { s.push("channel-maixcam", s.maixcamForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"WhatsApp",
|
|
||||||
"WhatsApp bridge",
|
|
||||||
s.config.Channels.WhatsApp.Enabled,
|
|
||||||
func() { s.push("channel-whatsapp", s.whatsappForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"Feishu",
|
|
||||||
"Feishu bot settings",
|
|
||||||
s.config.Channels.Feishu.Enabled,
|
|
||||||
func() { s.push("channel-feishu", s.feishuForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"DingTalk",
|
|
||||||
"DingTalk bot settings",
|
|
||||||
s.config.Channels.DingTalk.Enabled,
|
|
||||||
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"Slack",
|
|
||||||
"Slack bot settings",
|
|
||||||
s.config.Channels.Slack.Enabled,
|
|
||||||
func() { s.push("channel-slack", s.slackForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"Matrix",
|
|
||||||
"Matrix bot settings",
|
|
||||||
s.config.Channels.Matrix.Enabled,
|
|
||||||
func() { s.push("channel-matrix", s.matrixForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"LINE",
|
|
||||||
"LINE bot settings",
|
|
||||||
s.config.Channels.LINE.Enabled,
|
|
||||||
func() { s.push("channel-line", s.lineForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"OneBot",
|
|
||||||
"OneBot settings",
|
|
||||||
s.config.Channels.OneBot.Enabled,
|
|
||||||
func() { s.push("channel-onebot", s.onebotForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"WeCom",
|
|
||||||
"WeCom bot settings",
|
|
||||||
s.config.Channels.WeCom.Enabled,
|
|
||||||
func() { s.push("channel-wecom", s.wecomForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"WeCom App",
|
|
||||||
"WeCom App settings",
|
|
||||||
s.config.Channels.WeComApp.Enabled,
|
|
||||||
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) channelMenu() tview.Primitive {
|
|
||||||
menu := NewMenu("Channels", s.buildChannelMenuItems())
|
|
||||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
||||||
if event.Key() == tcell.KeyEsc {
|
|
||||||
s.pop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return event
|
|
||||||
})
|
|
||||||
return menu
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshChannelMenuFromState(menu *Menu, s *appState) {
|
|
||||||
menu.applyItems(s.buildChannelMenuItems())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) telegramForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.Telegram
|
|
||||||
form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
|
||||||
cfg.Token = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) {
|
|
||||||
cfg.Proxy = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) discordForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.Discord
|
|
||||||
form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
|
||||||
cfg.Token = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) {
|
|
||||||
cfg.MentionOnly = checked
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) qqForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.QQ
|
|
||||||
form := baseChannelForm("QQ", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
|
||||||
cfg.AppID = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
|
||||||
cfg.AppSecret = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) maixcamForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.MaixCam
|
|
||||||
form := baseChannelForm("MaixCam", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Host", cfg.Host, 64, nil, func(text string) {
|
|
||||||
cfg.Host = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value })
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) whatsappForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.WhatsApp
|
|
||||||
form := baseChannelForm("WhatsApp", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) {
|
|
||||||
cfg.BridgeURL = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) feishuForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.Feishu
|
|
||||||
form := baseChannelForm("Feishu", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
|
||||||
cfg.AppID = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
|
||||||
cfg.AppSecret = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) {
|
|
||||||
cfg.EncryptKey = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) {
|
|
||||||
cfg.VerificationToken = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) dingtalkForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.DingTalk
|
|
||||||
form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) {
|
|
||||||
cfg.ClientID = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) {
|
|
||||||
cfg.ClientSecret = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) slackForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.Slack
|
|
||||||
form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) {
|
|
||||||
cfg.BotToken = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) {
|
|
||||||
cfg.AppToken = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) lineForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.LINE
|
|
||||||
form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) {
|
|
||||||
cfg.ChannelSecret = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) {
|
|
||||||
cfg.ChannelAccessToken = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
|
||||||
cfg.WebhookHost = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
|
||||||
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
|
||||||
cfg.WebhookPath = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) matrixForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.Matrix
|
|
||||||
form := baseChannelForm("Matrix", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Homeserver", cfg.Homeserver, 128, nil, func(text string) {
|
|
||||||
cfg.Homeserver = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) {
|
|
||||||
cfg.UserID = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
|
|
||||||
cfg.AccessToken = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) {
|
|
||||||
cfg.DeviceID = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddCheckbox("Join On Invite", cfg.JoinOnInvite, func(checked bool) {
|
|
||||||
cfg.JoinOnInvite = checked
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) onebotForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.OneBot
|
|
||||||
form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) {
|
|
||||||
cfg.WSUrl = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
|
|
||||||
cfg.AccessToken = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addIntField(
|
|
||||||
form,
|
|
||||||
"Reconnect Interval",
|
|
||||||
cfg.ReconnectInterval,
|
|
||||||
func(value int) { cfg.ReconnectInterval = value },
|
|
||||||
)
|
|
||||||
form.AddInputField(
|
|
||||||
"Group Trigger Prefix",
|
|
||||||
strings.Join(cfg.GroupTriggerPrefix, ","),
|
|
||||||
128,
|
|
||||||
nil,
|
|
||||||
func(text string) {
|
|
||||||
cfg.GroupTriggerPrefix = splitCSV(text)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) wecomForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.WeCom
|
|
||||||
form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
|
||||||
cfg.Token = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
|
||||||
cfg.EncodingAESKey = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) {
|
|
||||||
cfg.WebhookURL = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
|
||||||
cfg.WebhookHost = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
|
||||||
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
|
||||||
cfg.WebhookPath = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
addIntField(
|
|
||||||
form,
|
|
||||||
"Reply Timeout",
|
|
||||||
cfg.ReplyTimeout,
|
|
||||||
func(value int) { cfg.ReplyTimeout = value },
|
|
||||||
)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) wecomAppForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.WeComApp
|
|
||||||
form := baseChannelForm("WeCom App", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) {
|
|
||||||
cfg.CorpID = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) {
|
|
||||||
cfg.CorpSecret = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value })
|
|
||||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
|
||||||
cfg.Token = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
|
||||||
cfg.EncodingAESKey = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
|
||||||
cfg.WebhookHost = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
|
||||||
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
|
||||||
cfg.WebhookPath = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
addIntField(
|
|
||||||
form,
|
|
||||||
"Reply Timeout",
|
|
||||||
cfg.ReplyTimeout,
|
|
||||||
func(value int) { cfg.ReplyTimeout = value },
|
|
||||||
)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) {
|
|
||||||
return func(v bool) {
|
|
||||||
*enabledPtr = v
|
|
||||||
s.dirty = true
|
|
||||||
refreshMainMenuIfPresent(s)
|
|
||||||
if menu, ok := s.menus["channel"]; ok {
|
|
||||||
refreshChannelMenuFromState(menu, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) {
|
|
||||||
form.AddInputField("Allow From", strings.Join(*allowFrom, ","), 128, nil, func(text string) {
|
|
||||||
*allowFrom = splitCSV(text)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form {
|
|
||||||
form := tview.NewForm()
|
|
||||||
form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title))
|
|
||||||
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
|
||||||
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
|
||||||
form.AddCheckbox("Enabled", enabled, func(checked bool) {
|
|
||||||
onEnabled(checked)
|
|
||||||
})
|
|
||||||
return form
|
|
||||||
}
|
|
||||||
|
|
||||||
func wrapWithBack(form *tview.Form, s *appState) tview.Primitive {
|
|
||||||
form.AddButton("Back", func() {
|
|
||||||
s.pop()
|
|
||||||
})
|
|
||||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
||||||
if event.Key() == tcell.KeyEsc {
|
|
||||||
s.pop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return event
|
|
||||||
})
|
|
||||||
return form
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitCSV(input string) picoclawconfig.FlexibleStringSlice {
|
|
||||||
parts := strings.Split(strings.TrimSpace(input), ",")
|
|
||||||
cleaned := make([]string, 0, len(parts))
|
|
||||||
for _, part := range parts {
|
|
||||||
value := strings.TrimSpace(part)
|
|
||||||
if value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cleaned = append(cleaned, value)
|
|
||||||
}
|
|
||||||
return cleaned
|
|
||||||
}
|
|
||||||
|
|
||||||
func addIntField(form *tview.Form, label string, value int, onChange func(int)) {
|
|
||||||
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
|
||||||
var parsed int
|
|
||||||
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
|
||||||
onChange(parsed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) {
|
|
||||||
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
|
||||||
var parsed int64
|
|
||||||
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
|
||||||
onChange(parsed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func channelItem(label, description string, enabled bool, action MenuAction) MenuItem {
|
|
||||||
item := MenuItem{
|
|
||||||
Label: label,
|
|
||||||
Description: description,
|
|
||||||
Action: action,
|
|
||||||
}
|
|
||||||
if !enabled {
|
|
||||||
color := tcell.ColorGray
|
|
||||||
item.MainColor = &color
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
//go:build !windows
|
|
||||||
// +build !windows
|
|
||||||
|
|
||||||
package ui
|
|
||||||
|
|
||||||
import "os/exec"
|
|
||||||
|
|
||||||
func isGatewayProcessRunning() bool {
|
|
||||||
cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
|
|
||||||
return cmd.Run() == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopGatewayProcess() error {
|
|
||||||
cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
|
|
||||||
return cmd.Run()
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
//go:build windows
|
|
||||||
// +build windows
|
|
||||||
|
|
||||||
package ui
|
|
||||||
|
|
||||||
import "os/exec"
|
|
||||||
|
|
||||||
func isGatewayProcessRunning() bool {
|
|
||||||
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe")
|
|
||||||
return cmd.Run() == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopGatewayProcess() error {
|
|
||||||
cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe")
|
|
||||||
return cmd.Run()
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gdamore/tcell/v2"
|
|
||||||
"github.com/rivo/tview"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MenuAction func()
|
|
||||||
|
|
||||||
type MenuItem struct {
|
|
||||||
Label string
|
|
||||||
Description string
|
|
||||||
Action MenuAction
|
|
||||||
Disabled bool
|
|
||||||
MainColor *tcell.Color
|
|
||||||
DescColor *tcell.Color
|
|
||||||
}
|
|
||||||
|
|
||||||
type Menu struct {
|
|
||||||
*tview.Table
|
|
||||||
items []MenuItem
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMenu(title string, items []MenuItem) *Menu {
|
|
||||||
table := tview.NewTable().SetSelectable(true, false)
|
|
||||||
table.SetBorder(true).SetTitle(title)
|
|
||||||
table.SetBorders(false)
|
|
||||||
menu := &Menu{Table: table, items: items}
|
|
||||||
menu.applyItems(items)
|
|
||||||
menu.SetSelectedFunc(func(row, _ int) {
|
|
||||||
if row < 0 || row >= len(menu.items) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item := menu.items[row]
|
|
||||||
if item.Disabled || item.Action == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item.Action()
|
|
||||||
})
|
|
||||||
menu.SetSelectedStyle(
|
|
||||||
tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor).
|
|
||||||
Background(tcell.NewRGBColor(189, 147, 249)),
|
|
||||||
)
|
|
||||||
return menu
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Menu) applyItems(items []MenuItem) {
|
|
||||||
m.items = items
|
|
||||||
m.Clear()
|
|
||||||
for row, item := range items {
|
|
||||||
label := item.Label
|
|
||||||
if item.Disabled && label != "" {
|
|
||||||
label = label + " (disabled)"
|
|
||||||
}
|
|
||||||
left := tview.NewTableCell(label)
|
|
||||||
right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight)
|
|
||||||
if item.MainColor != nil {
|
|
||||||
left.SetTextColor(*item.MainColor)
|
|
||||||
}
|
|
||||||
if item.DescColor != nil {
|
|
||||||
right.SetTextColor(*item.DescColor)
|
|
||||||
} else {
|
|
||||||
right.SetTextColor(tview.Styles.TertiaryTextColor)
|
|
||||||
}
|
|
||||||
if item.Disabled {
|
|
||||||
left.SetTextColor(tcell.ColorGray)
|
|
||||||
right.SetTextColor(tcell.ColorGray)
|
|
||||||
}
|
|
||||||
m.SetCell(row, 0, left)
|
|
||||||
m.SetCell(row, 1, right)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,399 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
|
||||||
"github.com/rivo/tview"
|
|
||||||
|
|
||||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *appState) modelMenu() tview.Primitive {
|
|
||||||
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
|
|
||||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
|
||||||
for i := range s.config.ModelList {
|
|
||||||
index := i
|
|
||||||
model := s.config.ModelList[i]
|
|
||||||
isValid := isModelValid(model)
|
|
||||||
desc := model.APIBase
|
|
||||||
if desc == "" {
|
|
||||||
desc = model.AuthMethod
|
|
||||||
}
|
|
||||||
if desc == "" {
|
|
||||||
desc = "api_key required"
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
|
||||||
if model.ModelName == currentModel && currentModel != "" {
|
|
||||||
label = "* " + label
|
|
||||||
}
|
|
||||||
isSelected := model.ModelName == currentModel && currentModel != ""
|
|
||||||
items = append(items, MenuItem{
|
|
||||||
Label: label,
|
|
||||||
Description: desc,
|
|
||||||
MainColor: modelStatusColor(isValid, isSelected),
|
|
||||||
Action: func() {
|
|
||||||
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Add model entry appended at the end so the models map to rows 1..N
|
|
||||||
items = append(items,
|
|
||||||
MenuItem{
|
|
||||||
Label: "**Add model**",
|
|
||||||
Description: "Append a new model entry",
|
|
||||||
Action: func() {
|
|
||||||
newName := s.nextAvailableModelName("new-model")
|
|
||||||
s.addModel(
|
|
||||||
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
|
||||||
)
|
|
||||||
s.push(
|
|
||||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
|
||||||
s.modelForm(len(s.config.ModelList)-1),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
menu := NewMenu("Models", items)
|
|
||||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
||||||
if event.Key() == tcell.KeyEsc {
|
|
||||||
s.pop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if event.Rune() == ' ' {
|
|
||||||
row, _ := menu.GetSelection()
|
|
||||||
if row >= 0 && row < len(s.config.ModelList) {
|
|
||||||
model := s.config.ModelList[row]
|
|
||||||
if !isModelValid(model) {
|
|
||||||
s.showMessage(
|
|
||||||
"Invalid model",
|
|
||||||
"Select a model with api_key or oauth auth_method",
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
s.config.Agents.Defaults.Model = model.ModelName
|
|
||||||
s.dirty = true
|
|
||||||
refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList)
|
|
||||||
refreshMainMenuIfPresent(s)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return event
|
|
||||||
})
|
|
||||||
return menu
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) modelForm(index int) tview.Primitive {
|
|
||||||
model := &s.config.ModelList[index]
|
|
||||||
form := tview.NewForm()
|
|
||||||
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
|
||||||
|
|
||||||
addInput(form, "Model Name", model.ModelName, func(value string) {
|
|
||||||
if value == "" {
|
|
||||||
s.showMessage("Invalid model name", "Model Name cannot be empty")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.modelNameExists(value, index) {
|
|
||||||
s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
oldName := model.ModelName
|
|
||||||
model.ModelName = value
|
|
||||||
if s.config.Agents.Defaults.Model == oldName {
|
|
||||||
s.config.Agents.Defaults.Model = value
|
|
||||||
}
|
|
||||||
s.dirty = true
|
|
||||||
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
|
||||||
refreshMainMenuIfPresent(s)
|
|
||||||
if menu, ok := s.menus["model"]; ok {
|
|
||||||
refreshModelMenuFromState(menu, s)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
addInput(form, "Model", model.Model, func(value string) {
|
|
||||||
model.Model = value
|
|
||||||
s.dirty = true
|
|
||||||
refreshMainMenuIfPresent(s)
|
|
||||||
if menu, ok := s.menus["model"]; ok {
|
|
||||||
refreshModelMenuFromState(menu, s)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
addInput(form, "API Base", model.APIBase, func(value string) {
|
|
||||||
model.APIBase = value
|
|
||||||
s.dirty = true
|
|
||||||
refreshMainMenuIfPresent(s)
|
|
||||||
if menu, ok := s.menus["model"]; ok {
|
|
||||||
refreshModelMenuFromState(menu, s)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
addInput(form, "API Key", model.APIKey, func(value string) {
|
|
||||||
model.APIKey = value
|
|
||||||
s.dirty = true
|
|
||||||
refreshMainMenuIfPresent(s)
|
|
||||||
if menu, ok := s.menus["model"]; ok {
|
|
||||||
refreshModelMenuFromState(menu, s)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
addInput(form, "Proxy", model.Proxy, func(value string) {
|
|
||||||
model.Proxy = value
|
|
||||||
})
|
|
||||||
addInput(form, "Auth Method", model.AuthMethod, func(value string) {
|
|
||||||
model.AuthMethod = value
|
|
||||||
s.dirty = true
|
|
||||||
refreshMainMenuIfPresent(s)
|
|
||||||
if menu, ok := s.menus["model"]; ok {
|
|
||||||
refreshModelMenuFromState(menu, s)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
addInput(form, "Connect Mode", model.ConnectMode, func(value string) {
|
|
||||||
model.ConnectMode = value
|
|
||||||
})
|
|
||||||
addInput(form, "Workspace", model.Workspace, func(value string) {
|
|
||||||
model.Workspace = value
|
|
||||||
})
|
|
||||||
addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) {
|
|
||||||
model.MaxTokensField = value
|
|
||||||
})
|
|
||||||
addIntInput(form, "RPM", model.RPM, func(value int) {
|
|
||||||
model.RPM = value
|
|
||||||
})
|
|
||||||
addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) {
|
|
||||||
model.RequestTimeout = value
|
|
||||||
})
|
|
||||||
|
|
||||||
form.AddButton("Delete", func() {
|
|
||||||
pageName := "confirm-delete-model"
|
|
||||||
if s.pages.HasPage(pageName) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modal := tview.NewModal().
|
|
||||||
SetText("Are you sure you want to delete this model?").
|
|
||||||
AddButtons([]string{"Cancel", "Delete"}).
|
|
||||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
|
||||||
s.pages.RemovePage(pageName)
|
|
||||||
if buttonLabel == "Delete" {
|
|
||||||
s.deleteModel(index)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
modal.SetTitle("Confirm Delete").SetBorder(true)
|
|
||||||
s.pages.AddPage(pageName, modal, true, true)
|
|
||||||
})
|
|
||||||
form.AddButton("Test", func() {
|
|
||||||
s.testModel(model)
|
|
||||||
})
|
|
||||||
form.AddButton("Back", func() {
|
|
||||||
s.pop()
|
|
||||||
})
|
|
||||||
|
|
||||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
||||||
if event.Key() == tcell.KeyEsc {
|
|
||||||
s.pop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return event
|
|
||||||
})
|
|
||||||
return form
|
|
||||||
}
|
|
||||||
|
|
||||||
func addInput(form *tview.Form, label, value string, onChange func(string)) {
|
|
||||||
form.AddInputField(label, value, 128, nil, func(text string) {
|
|
||||||
onChange(strings.TrimSpace(text))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func addIntInput(form *tview.Form, label string, value int, onChange func(int)) {
|
|
||||||
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
|
||||||
var parsed int
|
|
||||||
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
|
||||||
onChange(parsed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) addModel(model picoclawconfig.ModelConfig) {
|
|
||||||
s.config.ModelList = append(s.config.ModelList, model)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) deleteModel(index int) {
|
|
||||||
if index < 0 || index >= len(s.config.ModelList) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...)
|
|
||||||
s.pop()
|
|
||||||
}
|
|
||||||
|
|
||||||
func modelStatusColor(valid bool, selected bool) *tcell.Color {
|
|
||||||
if valid {
|
|
||||||
color := tview.Styles.PrimaryTextColor
|
|
||||||
return &color
|
|
||||||
}
|
|
||||||
color := tcell.ColorGray
|
|
||||||
return &color
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
|
||||||
for i, model := range models {
|
|
||||||
row := i
|
|
||||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
|
||||||
isValid := isModelValid(model)
|
|
||||||
if model.ModelName == currentModel && currentModel != "" {
|
|
||||||
label = "* " + label
|
|
||||||
}
|
|
||||||
cell := menu.GetCell(row, 0)
|
|
||||||
if cell != nil {
|
|
||||||
cell.SetText(label)
|
|
||||||
isSelected := model.ModelName == currentModel && currentModel != ""
|
|
||||||
color := modelStatusColor(isValid, isSelected)
|
|
||||||
if color != nil {
|
|
||||||
cell.SetTextColor(*color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshModelMenuFromState(menu *Menu, s *appState) {
|
|
||||||
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
|
|
||||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
|
||||||
for i := range s.config.ModelList {
|
|
||||||
index := i
|
|
||||||
model := s.config.ModelList[i]
|
|
||||||
isValid := isModelValid(model)
|
|
||||||
desc := model.APIBase
|
|
||||||
if desc == "" {
|
|
||||||
desc = model.AuthMethod
|
|
||||||
}
|
|
||||||
if desc == "" {
|
|
||||||
desc = "api_key required"
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
|
||||||
if model.ModelName == currentModel && currentModel != "" {
|
|
||||||
label = "* " + label
|
|
||||||
}
|
|
||||||
isSelected := model.ModelName == currentModel && currentModel != ""
|
|
||||||
items = append(items, MenuItem{
|
|
||||||
Label: label,
|
|
||||||
Description: desc,
|
|
||||||
MainColor: modelStatusColor(isValid, isSelected),
|
|
||||||
Action: func() {
|
|
||||||
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
items = append(items,
|
|
||||||
MenuItem{
|
|
||||||
Label: "**Add Model**",
|
|
||||||
Description: "Append a new model entry",
|
|
||||||
Action: func() {
|
|
||||||
newName := s.nextAvailableModelName("new-model")
|
|
||||||
s.addModel(
|
|
||||||
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
|
||||||
)
|
|
||||||
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
menu.applyItems(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isModelValid(model picoclawconfig.ModelConfig) bool {
|
|
||||||
hasKey := strings.TrimSpace(model.APIKey) != "" ||
|
|
||||||
strings.TrimSpace(model.AuthMethod) == "oauth"
|
|
||||||
hasModel := strings.TrimSpace(model.Model) != ""
|
|
||||||
return hasKey && hasModel
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) modelNameExists(name string, excludeIndex int) bool {
|
|
||||||
target := strings.TrimSpace(name)
|
|
||||||
if target == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for i := range s.config.ModelList {
|
|
||||||
if i == excludeIndex {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(s.config.ModelList[i].ModelName) == target {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) nextAvailableModelName(base string) string {
|
|
||||||
name := strings.TrimSpace(base)
|
|
||||||
if name == "" {
|
|
||||||
name = "new-model"
|
|
||||||
}
|
|
||||||
if !s.modelNameExists(name, -1) {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
for i := 2; ; i++ {
|
|
||||||
candidate := fmt.Sprintf("%s-%d", name, i)
|
|
||||||
if !s.modelNameExists(candidate, -1) {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
|
||||||
if model == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(model.APIKey) == "" {
|
|
||||||
s.showMessage("Missing API Key", "Set api_key before testing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
base := strings.TrimSpace(model.APIBase)
|
|
||||||
if base == "" {
|
|
||||||
s.showMessage("Missing API Base", "Set api_base before testing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modelID := strings.TrimSpace(model.Model)
|
|
||||||
if modelID == "" {
|
|
||||||
s.showMessage("Missing Model", "Set model before testing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(modelID, "openai/") {
|
|
||||||
s.showMessage("Unsupported model", "Only openai/* models are supported for test")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modelName := strings.TrimPrefix(modelID, "openai/")
|
|
||||||
endpoint := strings.TrimRight(base, "/") + "/chat/completions"
|
|
||||||
|
|
||||||
payload := fmt.Sprintf(
|
|
||||||
`{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`,
|
|
||||||
modelName,
|
|
||||||
)
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload))
|
|
||||||
if err != nil {
|
|
||||||
s.showMessage("Test failed", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey))
|
|
||||||
|
|
||||||
resp, err := client.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
s.showMessage("Test failed", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
||||||
s.showMessage("Test OK", resp.Status)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
||||||
if err != nil {
|
|
||||||
s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.showMessage(
|
|
||||||
"Test failed",
|
|
||||||
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gdamore/tcell/v2"
|
|
||||||
"github.com/rivo/tview"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
colorBlue = "[#3e5db9]"
|
|
||||||
colorRed = "[#d54646]"
|
|
||||||
banner = "\r\n[::b]" +
|
|
||||||
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
|
||||||
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
|
||||||
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
|
|
||||||
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
|
|
||||||
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
|
||||||
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
|
|
||||||
"[:]"
|
|
||||||
)
|
|
||||||
|
|
||||||
func applyStyles() {
|
|
||||||
tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22)
|
|
||||||
tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53)
|
|
||||||
tview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32)
|
|
||||||
tview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255)
|
|
||||||
tview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198)
|
|
||||||
tview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253)
|
|
||||||
tview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255)
|
|
||||||
tview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123)
|
|
||||||
tview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253)
|
|
||||||
tview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22)
|
|
||||||
tview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249)
|
|
||||||
}
|
|
||||||
|
|
||||||
func bannerView() *tview.TextView {
|
|
||||||
text := tview.NewTextView()
|
|
||||||
text.SetDynamicColors(true)
|
|
||||||
text.SetTextAlign(tview.AlignCenter)
|
|
||||||
text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
|
|
||||||
text.SetText(banner)
|
|
||||||
text.SetBorder(false)
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch"
|
|
||||||
|
|
||||||
func footerView() *tview.TextView {
|
|
||||||
text := tview.NewTextView()
|
|
||||||
text.SetTextAlign(tview.AlignCenter)
|
|
||||||
text.SetText(footerText)
|
|
||||||
text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor)
|
|
||||||
text.SetTextColor(tview.Styles.PrimaryTextColor)
|
|
||||||
text.SetBorder(false)
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,48 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui"
|
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := ui.Run(); err != nil {
|
configPath := tuicfg.DefaultConfigPath()
|
||||||
fmt.Fprintln(os.Stderr, err)
|
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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
325
cmd/picoclaw-launcher-tui/ui/app.go
Normal file
325
cmd/picoclaw-launcher-tui/ui/app.go
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// App is the root TUI application.
|
||||||
|
type App struct {
|
||||||
|
tapp *tview.Application
|
||||||
|
pages *tview.Pages
|
||||||
|
pageStack []string
|
||||||
|
cfg *tuicfg.TUIConfig
|
||||||
|
configPath string
|
||||||
|
pageRefreshFns map[string]func()
|
||||||
|
headerModelTV *tview.TextView
|
||||||
|
modalOpen map[string]bool
|
||||||
|
|
||||||
|
// OnModelSelected is called when a model is selected in the UI.
|
||||||
|
// Can be nil to disable.
|
||||||
|
OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string)
|
||||||
|
|
||||||
|
modelCache map[string][]modelEntry
|
||||||
|
modelCacheMu sync.RWMutex
|
||||||
|
refreshMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheKey returns the map key for a (scheme, user) pair.
|
||||||
|
func cacheKey(schemeName, userName string) string {
|
||||||
|
return fmt.Sprintf("%s/%s", schemeName, userName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedModels returns a defensive copy of the cached model list for a user (may be nil).
|
||||||
|
func (a *App) cachedModels(schemeName, userName string) []modelEntry {
|
||||||
|
a.modelCacheMu.RLock()
|
||||||
|
defer a.modelCacheMu.RUnlock()
|
||||||
|
entries := a.modelCache[cacheKey(schemeName, userName)]
|
||||||
|
return append([]modelEntry(nil), entries...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshModelCache fetches models for every user in the config concurrently.
|
||||||
|
// Serialized by refreshMu so concurrent calls don't race on the cache map.
|
||||||
|
// When all fetches complete it calls onDone via QueueUpdateDraw.
|
||||||
|
func (a *App) refreshModelCache(onDone func()) {
|
||||||
|
go func() {
|
||||||
|
a.refreshMu.Lock()
|
||||||
|
defer a.refreshMu.Unlock()
|
||||||
|
|
||||||
|
users := a.cfg.Provider.Users
|
||||||
|
schemes := a.cfg.Provider.Schemes
|
||||||
|
|
||||||
|
schemeURL := make(map[string]string, len(schemes))
|
||||||
|
for _, s := range schemes {
|
||||||
|
schemeURL[s.Name] = s.BaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, u := range users {
|
||||||
|
baseURL, ok := schemeURL[u.Scheme]
|
||||||
|
if !ok || baseURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if u.Key == "" {
|
||||||
|
a.modelCacheMu.Lock()
|
||||||
|
if a.modelCache == nil {
|
||||||
|
a.modelCache = make(map[string][]modelEntry)
|
||||||
|
}
|
||||||
|
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
|
||||||
|
a.modelCacheMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
bURL := baseURL
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
entries, err := fetchModels(bURL, u.Key)
|
||||||
|
a.modelCacheMu.Lock()
|
||||||
|
if a.modelCache == nil {
|
||||||
|
a.modelCache = make(map[string][]modelEntry)
|
||||||
|
}
|
||||||
|
if err != nil || len(entries) == 0 {
|
||||||
|
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
|
||||||
|
} else {
|
||||||
|
a.modelCache[cacheKey(u.Scheme, u.Name)] = entries
|
||||||
|
}
|
||||||
|
a.modelCacheMu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if onDone != nil {
|
||||||
|
a.tapp.QueueUpdateDraw(onDone)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates and wires up the TUI application.
|
||||||
|
func New(cfg *tuicfg.TUIConfig, configPath string) *App {
|
||||||
|
// Cyberpunk Theme Colors
|
||||||
|
// Dark background
|
||||||
|
tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void
|
||||||
|
tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo
|
||||||
|
tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40)
|
||||||
|
|
||||||
|
// Borders and Titles
|
||||||
|
tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||||
|
tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||||
|
tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
|
||||||
|
|
||||||
|
// Text
|
||||||
|
tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white
|
||||||
|
tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||||
|
tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime
|
||||||
|
tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black
|
||||||
|
tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
|
||||||
|
|
||||||
|
a := &App{
|
||||||
|
tapp: tview.NewApplication(),
|
||||||
|
pages: tview.NewPages(),
|
||||||
|
pageStack: []string{},
|
||||||
|
cfg: cfg,
|
||||||
|
configPath: configPath,
|
||||||
|
pageRefreshFns: make(map[string]func()),
|
||||||
|
modalOpen: make(map[string]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
if len(a.modalOpen) > 0 {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
return a.goBack()
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
a.buildPages()
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the TUI event loop.
|
||||||
|
func (a *App) Run() error {
|
||||||
|
return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) buildPages() {
|
||||||
|
a.pages.AddPage("home", a.newHomePage(), true, true)
|
||||||
|
a.pageStack = []string{"home"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) navigateTo(name string, page tview.Primitive) {
|
||||||
|
a.pages.RemovePage(name)
|
||||||
|
a.pages.AddPage(name, page, true, false)
|
||||||
|
a.pageStack = append(a.pageStack, name)
|
||||||
|
a.pages.SwitchToPage(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) goBack() *tcell.EventKey {
|
||||||
|
if len(a.pageStack) <= 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
popped := a.pageStack[len(a.pageStack)-1]
|
||||||
|
a.pageStack = a.pageStack[:len(a.pageStack)-1]
|
||||||
|
a.pages.RemovePage(popped)
|
||||||
|
prev := a.pageStack[len(a.pageStack)-1]
|
||||||
|
if fn, ok := a.pageRefreshFns[prev]; ok {
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
if prev == "home" && a.headerModelTV != nil {
|
||||||
|
a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ")
|
||||||
|
}
|
||||||
|
a.pages.SwitchToPage(prev)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) showModal(name string, primitive tview.Primitive) {
|
||||||
|
a.modalOpen[name] = true
|
||||||
|
a.pages.AddPage(name, primitive, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) hideModal(name string) {
|
||||||
|
delete(a.modalOpen, name)
|
||||||
|
a.pages.HidePage(name)
|
||||||
|
a.pages.RemovePage(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) save() {
|
||||||
|
if err := tuicfg.Save(a.configPath, a.cfg); err != nil {
|
||||||
|
a.showError("save failed: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) showError(msg string) {
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText(" [red::b]ERROR[-::-]\n\n" + msg).
|
||||||
|
AddButtons([]string{"OK"}).
|
||||||
|
SetDoneFunc(func(_ int, _ string) {
|
||||||
|
a.hideModal("error")
|
||||||
|
})
|
||||||
|
// Cyberpunk Modal Style
|
||||||
|
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||||
|
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||||
|
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red
|
||||||
|
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||||
|
a.showModal("error", modal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) confirmDelete(label string, onConfirm func()) {
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]").
|
||||||
|
AddButtons([]string{"Delete", "Cancel"}).
|
||||||
|
SetDoneFunc(func(_ int, buttonLabel string) {
|
||||||
|
a.hideModal("confirm-delete")
|
||||||
|
if buttonLabel == "Delete" {
|
||||||
|
onConfirm()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Cyberpunk Modal Style
|
||||||
|
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||||
|
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||||
|
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger
|
||||||
|
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||||
|
a.showModal("confirm-delete", modal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive {
|
||||||
|
return tview.NewFlex().
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false).
|
||||||
|
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false).
|
||||||
|
AddItem(form, height, 1, true).
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true).
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hintBar(text string) *tview.TextView {
|
||||||
|
tv := tview.NewTextView().
|
||||||
|
SetText(text).
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetTextAlign(tview.AlignCenter).
|
||||||
|
SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan
|
||||||
|
tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo
|
||||||
|
return tv
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive {
|
||||||
|
var modelTV *tview.TextView
|
||||||
|
if pageID == "home" {
|
||||||
|
if a.headerModelTV == nil {
|
||||||
|
a.headerModelTV = tview.NewTextView()
|
||||||
|
a.headerModelTV.SetTextAlign(tview.AlignRight).
|
||||||
|
SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
}
|
||||||
|
modelTV = a.headerModelTV
|
||||||
|
modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ")
|
||||||
|
} else {
|
||||||
|
modelTV = tview.NewTextView()
|
||||||
|
modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
}
|
||||||
|
|
||||||
|
headerLeft := tview.NewTextView().
|
||||||
|
SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///").
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
header := tview.NewFlex().
|
||||||
|
AddItem(headerLeft, 0, 1, false).
|
||||||
|
AddItem(modelTV, 0, 1, false)
|
||||||
|
|
||||||
|
sidebar := tview.NewTextView().
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetWrap(false)
|
||||||
|
sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||||
|
|
||||||
|
// Cyberpunk Sidebar Styling
|
||||||
|
activePrefix := "[#39ff14::b]>> " // Neon Lime arrow
|
||||||
|
activeSuffix := "[-]"
|
||||||
|
inactivePrefix := "[#808080] "
|
||||||
|
inactiveSuffix := "[-]"
|
||||||
|
|
||||||
|
sbText := "\n\n" // Top padding
|
||||||
|
|
||||||
|
menuItem := func(id, label string) string {
|
||||||
|
if pageID == id {
|
||||||
|
return activePrefix + label + activeSuffix + "\n\n"
|
||||||
|
}
|
||||||
|
return inactivePrefix + label + inactiveSuffix + "\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
sbText += menuItem("home", "HOME")
|
||||||
|
sbText += menuItem("schemes", "SCHEMES")
|
||||||
|
sbText += menuItem("users", "USERS")
|
||||||
|
sbText += menuItem("models", "MODELS")
|
||||||
|
sbText += menuItem("channels", "CHANNELS")
|
||||||
|
sbText += menuItem("gateway", "GATEWAY")
|
||||||
|
|
||||||
|
sidebar.SetText(sbText)
|
||||||
|
|
||||||
|
footer := hintBar(hint)
|
||||||
|
|
||||||
|
grid := tview.NewGrid().
|
||||||
|
SetRows(1, 0, 1).
|
||||||
|
SetColumns(20, 0). // Slightly wider sidebar
|
||||||
|
AddItem(header, 0, 0, 1, 2, 0, 0, false).
|
||||||
|
AddItem(sidebar, 1, 0, 1, 1, 0, 0, false).
|
||||||
|
AddItem(content, 1, 1, 1, 1, 0, 0, true).
|
||||||
|
AddItem(footer, 2, 0, 1, 2, 0, 0, false)
|
||||||
|
|
||||||
|
// Add a border around the content area if possible, or ensure content has its own border
|
||||||
|
// grid.SetBorders(false) // Grid borders usually look bad, handled by components
|
||||||
|
|
||||||
|
return grid
|
||||||
|
}
|
||||||
202
cmd/picoclaw-launcher-tui/ui/channels.go
Normal file
202
cmd/picoclaw-launcher-tui/ui/channels.go
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) newChannelsPage() tview.Primitive {
|
||||||
|
list := tview.NewList()
|
||||||
|
list.SetBorder(true).
|
||||||
|
SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
|
||||||
|
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
|
||||||
|
list.SetSelectedStyle(
|
||||||
|
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)),
|
||||||
|
)
|
||||||
|
list.SetHighlightFullLine(true)
|
||||||
|
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
rebuild := func() {
|
||||||
|
sel := list.GetCurrentItem()
|
||||||
|
list.Clear()
|
||||||
|
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
home = "."
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(home, ".picoclaw", "config.json")
|
||||||
|
|
||||||
|
var cfg map[string]any
|
||||||
|
if data, err := os.ReadFile(configPath); err == nil {
|
||||||
|
_ = json.Unmarshal(data, &cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if chRaw, ok := cfg["channels"].(map[string]any); ok {
|
||||||
|
for name, ch := range chRaw {
|
||||||
|
chMap, ok := ch.(map[string]any)
|
||||||
|
enabled := "disabled"
|
||||||
|
if ok {
|
||||||
|
if e, ok := chMap["enabled"].(bool); ok && e {
|
||||||
|
enabled = "enabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() {
|
||||||
|
a.showChannelEditForm(configPath, name, chMap)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sel >= 0 && sel < list.GetItemCount() {
|
||||||
|
list.SetCurrentItem(sel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rebuild()
|
||||||
|
|
||||||
|
a.pageRefreshFns["channels"] = rebuild
|
||||||
|
|
||||||
|
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
return a.goBack()
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) {
|
||||||
|
form := tview.NewForm()
|
||||||
|
form.SetBorder(true).
|
||||||
|
SetTitle(" [::b]EDIT CHANNEL ").
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||||
|
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||||
|
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||||
|
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||||
|
|
||||||
|
fields := make(map[string]*tview.InputField)
|
||||||
|
var nameField *tview.InputField
|
||||||
|
|
||||||
|
if channelName == "" {
|
||||||
|
nameField = tview.NewInputField().
|
||||||
|
SetLabel("Channel Name").
|
||||||
|
SetText("").
|
||||||
|
SetFieldWidth(28)
|
||||||
|
form.AddFormItem(nameField)
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range existing {
|
||||||
|
if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valStr := fmt.Sprintf("%v", v)
|
||||||
|
field := tview.NewInputField().
|
||||||
|
SetLabel(k).
|
||||||
|
SetText(valStr).
|
||||||
|
SetFieldWidth(28)
|
||||||
|
form.AddFormItem(field)
|
||||||
|
fields[k] = field
|
||||||
|
}
|
||||||
|
|
||||||
|
form.AddButton("SAVE", func() {
|
||||||
|
var cfg map[string]any
|
||||||
|
if data, err := os.ReadFile(configPath); err == nil {
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
cfg = make(map[string]any)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cfg = make(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := cfg["channels"]; !ok {
|
||||||
|
cfg["channels"] = make(map[string]any)
|
||||||
|
}
|
||||||
|
channels, ok := cfg["channels"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
channels = make(map[string]any)
|
||||||
|
cfg["channels"] = channels
|
||||||
|
}
|
||||||
|
|
||||||
|
finalName := channelName
|
||||||
|
if channelName == "" {
|
||||||
|
if nameField == nil || nameField.GetText() == "" {
|
||||||
|
a.showError("Channel name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
finalName = nameField.GetText()
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := make(map[string]any)
|
||||||
|
if existing != nil {
|
||||||
|
for k, v := range existing {
|
||||||
|
updated[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, field := range fields {
|
||||||
|
val := field.GetText()
|
||||||
|
if val == "true" {
|
||||||
|
updated[k] = true
|
||||||
|
} else if val == "false" {
|
||||||
|
updated[k] = false
|
||||||
|
} else if num, err := strconv.Atoi(val); err == nil {
|
||||||
|
updated[k] = num
|
||||||
|
} else {
|
||||||
|
updated[k] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if channelName != "" && finalName != channelName {
|
||||||
|
delete(channels, channelName)
|
||||||
|
}
|
||||||
|
channels[finalName] = updated
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
a.showError(fmt.Sprintf("Failed to save config: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
|
||||||
|
a.showError(fmt.Sprintf("Failed to create config directory: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(configPath, data, 0o600); err != nil {
|
||||||
|
a.showError(fmt.Sprintf("Failed to write config: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.hideModal("channel-edit")
|
||||||
|
a.goBack()
|
||||||
|
})
|
||||||
|
|
||||||
|
form.AddButton("CANCEL", func() {
|
||||||
|
a.hideModal("channel-edit")
|
||||||
|
})
|
||||||
|
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
a.hideModal("channel-edit")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
a.showModal("channel-edit", centeredForm(form, 4, 20))
|
||||||
|
}
|
||||||
261
cmd/picoclaw-launcher-tui/ui/gateway.go
Normal file
261
cmd/picoclaw-launcher-tui/ui/gateway.go
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
const pidFileName = "gateway.pid"
|
||||||
|
|
||||||
|
type gatewayStatus struct {
|
||||||
|
running bool
|
||||||
|
pid int
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPidPath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
home = "."
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".picoclaw", pidFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProcessRunning(pid int) bool {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid))
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(string(output), strconv.Itoa(pid))
|
||||||
|
} else if runtime.GOOS == "darwin" {
|
||||||
|
cmd := exec.Command("ps", "aux")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(string(output), fmt.Sprintf(" %d ", pid))
|
||||||
|
}
|
||||||
|
// Linux
|
||||||
|
_, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGatewayStatus() gatewayStatus {
|
||||||
|
pidPath := getPidPath()
|
||||||
|
data, err := os.ReadFile(pidPath)
|
||||||
|
if err != nil {
|
||||||
|
return gatewayStatus{running: false}
|
||||||
|
}
|
||||||
|
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||||
|
if err != nil {
|
||||||
|
return gatewayStatus{running: false}
|
||||||
|
}
|
||||||
|
if !isProcessRunning(pid) {
|
||||||
|
os.Remove(pidPath)
|
||||||
|
return gatewayStatus{running: false}
|
||||||
|
}
|
||||||
|
return gatewayStatus{
|
||||||
|
running: true,
|
||||||
|
pid: pid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startGateway() error {
|
||||||
|
status := getGatewayStatus()
|
||||||
|
if status.running {
|
||||||
|
return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
pidPath := getPidPath()
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1")
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 & echo $! > "+pidPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
cmd := exec.Command(
|
||||||
|
"wmic",
|
||||||
|
"process",
|
||||||
|
"where",
|
||||||
|
"name='picoclaw.exe' and commandline like '%gateway%'",
|
||||||
|
"get",
|
||||||
|
"processid",
|
||||||
|
)
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get gateway PID: %w", err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
for _, line := range lines[1:] {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pid, err := strconv.Atoi(line)
|
||||||
|
if err == nil {
|
||||||
|
os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0o600)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status = getGatewayStatus()
|
||||||
|
if !status.running {
|
||||||
|
return fmt.Errorf("failed to start gateway")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopGateway() error {
|
||||||
|
status := getGatewayStatus()
|
||||||
|
if !status.running {
|
||||||
|
return fmt.Errorf("gateway is not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run()
|
||||||
|
} else {
|
||||||
|
err = exec.Command("kill", "-9", strconv.Itoa(status.pid)).Run()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多次尝试确认进程已停止
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if !isProcessRunning(status.pid) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Remove(getPidPath())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) newGatewayPage() tview.Primitive {
|
||||||
|
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||||
|
flex.SetBorder(true).
|
||||||
|
SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
flex.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
statusTV := tview.NewTextView().
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetTextAlign(tview.AlignCenter).
|
||||||
|
SetText("Checking status...")
|
||||||
|
statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
var updateStatus func()
|
||||||
|
|
||||||
|
// 使用List作为按钮,保证显示和交互正常
|
||||||
|
buttons := tview.NewList()
|
||||||
|
buttons.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
buttons.SetMainTextColor(tcell.ColorWhite)
|
||||||
|
buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||||
|
buttons.SetSelectedTextColor(tcell.ColorBlack)
|
||||||
|
|
||||||
|
buttons.AddItem(" [lime]START[white] ", "", 0, func() {
|
||||||
|
if !getGatewayStatus().running {
|
||||||
|
err := startGateway()
|
||||||
|
if err != nil {
|
||||||
|
a.showError(err.Error())
|
||||||
|
}
|
||||||
|
updateStatus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
buttons.AddItem(" [red]STOP[white] ", "", 0, func() {
|
||||||
|
if getGatewayStatus().running {
|
||||||
|
err := stopGateway()
|
||||||
|
if err != nil {
|
||||||
|
a.showError(err.Error())
|
||||||
|
}
|
||||||
|
updateStatus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
|
||||||
|
buttonFlex.
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false).
|
||||||
|
AddItem(buttons, 20, 1, true).
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false)
|
||||||
|
|
||||||
|
flex.
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false).
|
||||||
|
AddItem(statusTV, 3, 1, false).
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false).
|
||||||
|
AddItem(buttonFlex, 4, 1, true).
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false)
|
||||||
|
|
||||||
|
updateStatus = func() {
|
||||||
|
status := getGatewayStatus()
|
||||||
|
if status.running {
|
||||||
|
statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d", status.pid))
|
||||||
|
buttons.SetItemText(0, " [gray]START[white] ", "")
|
||||||
|
buttons.SetItemText(1, " [red]STOP[white] ", "")
|
||||||
|
} else {
|
||||||
|
statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A")
|
||||||
|
buttons.SetItemText(0, " [lime]START[white] ", "")
|
||||||
|
buttons.SetItemText(1, " [gray]STOP[white] ", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
a.tapp.QueueUpdateDraw(updateStatus)
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
originalInputCapture := flex.GetInputCapture()
|
||||||
|
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
close(done)
|
||||||
|
return a.goBack()
|
||||||
|
}
|
||||||
|
if originalInputCapture != nil {
|
||||||
|
return originalInputCapture(event)
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
a.pageRefreshFns["gateway"] = updateStatus
|
||||||
|
|
||||||
|
return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ")
|
||||||
|
}
|
||||||
70
cmd/picoclaw-launcher-tui/ui/home.go
Normal file
70
cmd/picoclaw-launcher-tui/ui/home.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) newHomePage() tview.Primitive {
|
||||||
|
list := tview.NewList()
|
||||||
|
list.SetBorder(true).
|
||||||
|
SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
|
||||||
|
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
|
||||||
|
list.SetSelectedStyle(
|
||||||
|
tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)),
|
||||||
|
)
|
||||||
|
list.SetHighlightFullLine(true)
|
||||||
|
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
rebuildList := func() {
|
||||||
|
sel := list.GetCurrentItem()
|
||||||
|
list.Clear()
|
||||||
|
list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() {
|
||||||
|
a.navigateTo("schemes", a.newSchemesPage())
|
||||||
|
})
|
||||||
|
list.AddItem(
|
||||||
|
"CHANNELS: Configure communication channels",
|
||||||
|
"Manage Telegram/Discord/WeChat channels",
|
||||||
|
'n',
|
||||||
|
func() {
|
||||||
|
a.navigateTo("channels", a.newChannelsPage())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() {
|
||||||
|
a.navigateTo("gateway", a.newGatewayPage())
|
||||||
|
})
|
||||||
|
list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() {
|
||||||
|
a.tapp.Suspend(func() {
|
||||||
|
cmd := exec.Command("picoclaw", "agent")
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
_ = cmd.Run()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() })
|
||||||
|
if sel >= 0 && sel < list.GetItemCount() {
|
||||||
|
list.SetCurrentItem(sel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rebuildList()
|
||||||
|
|
||||||
|
a.pageRefreshFns["home"] = rebuildList
|
||||||
|
|
||||||
|
return a.buildShell(
|
||||||
|
"home",
|
||||||
|
list,
|
||||||
|
" [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ",
|
||||||
|
)
|
||||||
|
}
|
||||||
200
cmd/picoclaw-launcher-tui/ui/models.go
Normal file
200
cmd/picoclaw-launcher-tui/ui/models.go
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type modelsAPIResponse struct {
|
||||||
|
Data []modelEntry `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive {
|
||||||
|
table := tview.NewTable().
|
||||||
|
SetBorders(false).
|
||||||
|
SetSelectable(true, false).
|
||||||
|
SetFixed(0, 0)
|
||||||
|
table.SetBorder(true).
|
||||||
|
SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
table.SetSelectedStyle(
|
||||||
|
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||||
|
)
|
||||||
|
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
var modelIDs []string
|
||||||
|
|
||||||
|
status := tview.NewTextView().
|
||||||
|
SetTextAlign(tview.AlignCenter).
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetText("[#ffff00]FETCHING MODELS...[-]")
|
||||||
|
status.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
flex := tview.NewFlex().
|
||||||
|
SetDirection(tview.FlexRow).
|
||||||
|
AddItem(status, 1, 0, false).
|
||||||
|
AddItem(table, 0, 1, false)
|
||||||
|
|
||||||
|
apiKey := a.resolveKey(schemeName, userName)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
var entries []modelEntry
|
||||||
|
var err error
|
||||||
|
if apiKey == "" {
|
||||||
|
err = fmt.Errorf("key is required")
|
||||||
|
} else {
|
||||||
|
entries, err = fetchModels(baseURL, apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.modelCacheMu.Lock()
|
||||||
|
if a.modelCache == nil {
|
||||||
|
a.modelCache = make(map[string][]modelEntry)
|
||||||
|
}
|
||||||
|
if err == nil && len(entries) > 0 {
|
||||||
|
a.modelCache[cacheKey(schemeName, userName)] = entries
|
||||||
|
} else {
|
||||||
|
a.modelCache[cacheKey(schemeName, userName)] = nil
|
||||||
|
}
|
||||||
|
a.modelCacheMu.Unlock()
|
||||||
|
|
||||||
|
a.tapp.QueueUpdateDraw(func() {
|
||||||
|
if err != nil {
|
||||||
|
status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error()))
|
||||||
|
table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)"))
|
||||||
|
a.tapp.SetFocus(table)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
status.SetText("[#ff2a2a]NO MODELS RETURNED[-]")
|
||||||
|
table.SetCell(0, 0, tview.NewTableCell(" (no models available)"))
|
||||||
|
a.tapp.SetFocus(table)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries)))
|
||||||
|
for i, m := range entries {
|
||||||
|
modelIDs = append(modelIDs, m.ID)
|
||||||
|
table.SetCell(i, 0,
|
||||||
|
tview.NewTableCell(fmt.Sprintf("%3d", i+1)).
|
||||||
|
SetAlign(tview.AlignRight).
|
||||||
|
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||||
|
SetSelectable(false),
|
||||||
|
)
|
||||||
|
table.SetCell(i, 1,
|
||||||
|
tview.NewTableCell(" "+m.ID).
|
||||||
|
SetAlign(tview.AlignLeft).
|
||||||
|
SetExpansion(1).
|
||||||
|
SetTextColor(tcell.NewHexColor(0xe0e0e0)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
a.tapp.SetFocus(table)
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
table.SetSelectedFunc(func(row, _ int) {
|
||||||
|
if row < 0 || row >= len(modelIDs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.cfg.Provider.Current = tuicfg.ProviderCurrent{
|
||||||
|
Scheme: schemeName,
|
||||||
|
User: userName,
|
||||||
|
Model: modelIDs[row],
|
||||||
|
}
|
||||||
|
a.save()
|
||||||
|
|
||||||
|
// Trigger model selected callback if set
|
||||||
|
if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" {
|
||||||
|
scheme := a.cfg.Provider.SchemeByName(schemeName)
|
||||||
|
if scheme == nil {
|
||||||
|
a.goBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var user tuicfg.User
|
||||||
|
for _, u := range a.cfg.Provider.Users {
|
||||||
|
if u.Scheme == schemeName && u.Name == userName {
|
||||||
|
user = u
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.OnModelSelected(*scheme, user, modelIDs[row])
|
||||||
|
}
|
||||||
|
|
||||||
|
a.goBack()
|
||||||
|
})
|
||||||
|
|
||||||
|
return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) resolveKey(schemeName, userName string) string {
|
||||||
|
for _, u := range a.cfg.Provider.Users {
|
||||||
|
if u.Scheme == schemeName && u.Name == userName {
|
||||||
|
return u.Key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
|
||||||
|
url := strings.TrimRight(baseURL, "/") + "/models"
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
if apiKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result modelsAPIResponse
|
||||||
|
if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 {
|
||||||
|
return result.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var arr []modelEntry
|
||||||
|
if err := json.Unmarshal(body, &arr); err == nil {
|
||||||
|
return arr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"decode response: unrecognized shape: %s",
|
||||||
|
strings.TrimSpace(string(body[:min(len(body), 256)])),
|
||||||
|
)
|
||||||
|
}
|
||||||
252
cmd/picoclaw-launcher-tui/ui/schemes.go
Normal file
252
cmd/picoclaw-launcher-tui/ui/schemes.go
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) newSchemesPage() tview.Primitive {
|
||||||
|
table := tview.NewTable().
|
||||||
|
SetBorders(false).
|
||||||
|
SetSelectable(true, false)
|
||||||
|
table.SetBorder(true).
|
||||||
|
SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
table.SetSelectedStyle(
|
||||||
|
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||||
|
)
|
||||||
|
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
rowToIdx := func(row int) int { return row / 2 }
|
||||||
|
|
||||||
|
selectedSchemeName := func() string {
|
||||||
|
row, _ := table.GetSelection()
|
||||||
|
idx := rowToIdx(row)
|
||||||
|
schemes := a.cfg.Provider.Schemes
|
||||||
|
if idx >= 0 && idx < len(schemes) {
|
||||||
|
return schemes[idx].Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuild := func() {
|
||||||
|
selName := selectedSchemeName()
|
||||||
|
table.Clear()
|
||||||
|
schemes := a.cfg.Provider.Schemes
|
||||||
|
for i, s := range schemes {
|
||||||
|
nameRow := i * 2
|
||||||
|
detailRow := nameRow + 1
|
||||||
|
|
||||||
|
table.SetCell(nameRow, 0,
|
||||||
|
tview.NewTableCell(" "+s.Name).
|
||||||
|
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
|
||||||
|
SetExpansion(1).
|
||||||
|
SetSelectable(true),
|
||||||
|
)
|
||||||
|
|
||||||
|
users := a.cfg.Provider.UsersForScheme(s.Name)
|
||||||
|
n := len(users)
|
||||||
|
m := 0
|
||||||
|
for _, u := range users {
|
||||||
|
if models := a.cachedModels(s.Name, u.Name); len(models) > 0 {
|
||||||
|
m++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table.SetCell(detailRow, 0,
|
||||||
|
tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)).
|
||||||
|
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||||
|
SetExpansion(1).
|
||||||
|
SetSelectable(false),
|
||||||
|
)
|
||||||
|
table.SetCell(detailRow, 1,
|
||||||
|
tview.NewTableCell("[#00f0ff]"+s.Type+" ").
|
||||||
|
SetAlign(tview.AlignRight).
|
||||||
|
SetSelectable(false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if selName != "" {
|
||||||
|
for i, s := range schemes {
|
||||||
|
if s.Name == selName {
|
||||||
|
table.Select(i*2, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if table.GetRowCount() > 0 {
|
||||||
|
table.Select(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rebuild()
|
||||||
|
|
||||||
|
a.refreshModelCache(rebuild)
|
||||||
|
a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) }
|
||||||
|
|
||||||
|
table.SetSelectedFunc(func(row, _ int) {
|
||||||
|
idx := rowToIdx(row)
|
||||||
|
schemes := a.cfg.Provider.Schemes
|
||||||
|
if idx < 0 || idx >= len(schemes) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := schemes[idx].Name
|
||||||
|
a.navigateTo("users", a.newUsersPage(name))
|
||||||
|
})
|
||||||
|
|
||||||
|
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
row, _ := table.GetSelection()
|
||||||
|
idx := rowToIdx(row)
|
||||||
|
schemes := a.cfg.Provider.Schemes
|
||||||
|
switch event.Rune() {
|
||||||
|
case 'a':
|
||||||
|
a.showSchemeForm(nil, func(s tuicfg.Scheme) {
|
||||||
|
a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
|
||||||
|
a.save()
|
||||||
|
a.refreshModelCache(rebuild)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
case 'e':
|
||||||
|
if idx < 0 || idx >= len(schemes) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
origName := schemes[idx].Name
|
||||||
|
orig := schemes[idx]
|
||||||
|
a.showSchemeForm(&orig, func(s tuicfg.Scheme) {
|
||||||
|
current := a.cfg.Provider.Schemes
|
||||||
|
for i, sc := range current {
|
||||||
|
if sc.Name == origName {
|
||||||
|
a.cfg.Provider.Schemes[i] = s
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.save()
|
||||||
|
a.refreshModelCache(func() {
|
||||||
|
rebuild()
|
||||||
|
for i, sc := range a.cfg.Provider.Schemes {
|
||||||
|
if sc.Name == s.Name {
|
||||||
|
table.Select(i*2, 0)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
case 'd':
|
||||||
|
if idx < 0 || idx >= len(schemes) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := schemes[idx].Name
|
||||||
|
a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
|
||||||
|
current := a.cfg.Provider.Schemes
|
||||||
|
newSchemes := make([]tuicfg.Scheme, 0, len(current))
|
||||||
|
for _, sc := range current {
|
||||||
|
if sc.Name != name {
|
||||||
|
newSchemes = append(newSchemes, sc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.cfg.Provider.Schemes = newSchemes
|
||||||
|
|
||||||
|
existing := a.cfg.Provider.Users
|
||||||
|
filtered := make([]tuicfg.User, 0, len(existing))
|
||||||
|
for _, u := range existing {
|
||||||
|
if u.Scheme != name {
|
||||||
|
filtered = append(filtered, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.cfg.Provider.Users = filtered
|
||||||
|
|
||||||
|
a.save()
|
||||||
|
a.refreshModelCache(rebuild)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
return a.buildShell(
|
||||||
|
"schemes",
|
||||||
|
table,
|
||||||
|
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
|
||||||
|
name := ""
|
||||||
|
baseURL := ""
|
||||||
|
schemeType := "openai-compatible"
|
||||||
|
title := " ADD SCHEME "
|
||||||
|
|
||||||
|
if existing != nil {
|
||||||
|
name = existing.Name
|
||||||
|
baseURL = existing.BaseURL
|
||||||
|
schemeType = existing.Type
|
||||||
|
title = " EDIT SCHEME "
|
||||||
|
}
|
||||||
|
|
||||||
|
typeOptions := []string{"openai-compatible", "anthropic"}
|
||||||
|
typeIdx := 0
|
||||||
|
for i, t := range typeOptions {
|
||||||
|
if t == schemeType {
|
||||||
|
typeIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form := tview.NewForm()
|
||||||
|
|
||||||
|
form.
|
||||||
|
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
|
||||||
|
AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }).
|
||||||
|
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
|
||||||
|
AddButton("SAVE", func() {
|
||||||
|
if name == "" {
|
||||||
|
a.showError("Name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if baseURL == "" {
|
||||||
|
a.showError("Base URL is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
for _, s := range a.cfg.Provider.Schemes {
|
||||||
|
if s.Name == name {
|
||||||
|
a.showError(fmt.Sprintf("Scheme name %q already exists", name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.hideModal("scheme-form")
|
||||||
|
onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType})
|
||||||
|
}).
|
||||||
|
AddButton("CANCEL", func() {
|
||||||
|
a.hideModal("scheme-form")
|
||||||
|
})
|
||||||
|
|
||||||
|
form.SetBorder(true).
|
||||||
|
SetTitle(" [::b]" + title + " ").
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||||
|
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||||
|
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||||
|
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
a.hideModal("scheme-form")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
a.showModal("scheme-form", centeredForm(form, 4, 12))
|
||||||
|
}
|
||||||
261
cmd/picoclaw-launcher-tui/ui/users.go
Normal file
261
cmd/picoclaw-launcher-tui/ui/users.go
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) newUsersPage(schemeName string) tview.Primitive {
|
||||||
|
table := tview.NewTable().
|
||||||
|
SetBorders(false).
|
||||||
|
SetSelectable(true, false)
|
||||||
|
table.SetBorder(true).
|
||||||
|
SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
table.SetSelectedStyle(
|
||||||
|
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||||
|
)
|
||||||
|
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
|
||||||
|
visibleUsers := func() []tuicfg.User {
|
||||||
|
var out []tuicfg.User
|
||||||
|
for _, u := range a.cfg.Provider.Users {
|
||||||
|
if u.Scheme == schemeName {
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
findUserGlobalIdx := func(userName string) int {
|
||||||
|
for i, u := range a.cfg.Provider.Users {
|
||||||
|
if u.Scheme == schemeName && u.Name == userName {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
rowToVisIdx := func(row int) int { return row / 2 }
|
||||||
|
|
||||||
|
selectedUserName := func() string {
|
||||||
|
row, _ := table.GetSelection()
|
||||||
|
users := visibleUsers()
|
||||||
|
visIdx := rowToVisIdx(row)
|
||||||
|
if visIdx >= 0 && visIdx < len(users) {
|
||||||
|
return users[visIdx].Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuild := func() {
|
||||||
|
selName := selectedUserName()
|
||||||
|
table.Clear()
|
||||||
|
users := visibleUsers()
|
||||||
|
for i, u := range users {
|
||||||
|
nameRow := i * 2
|
||||||
|
detailRow := nameRow + 1
|
||||||
|
|
||||||
|
table.SetCell(nameRow, 0,
|
||||||
|
tview.NewTableCell(" "+u.Name).
|
||||||
|
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
|
||||||
|
SetExpansion(1).
|
||||||
|
SetSelectable(true),
|
||||||
|
)
|
||||||
|
table.SetCell(nameRow, 1,
|
||||||
|
tview.NewTableCell("").
|
||||||
|
SetSelectable(false),
|
||||||
|
)
|
||||||
|
|
||||||
|
models := a.cachedModels(schemeName, u.Name)
|
||||||
|
var detailText string
|
||||||
|
if len(models) > 0 {
|
||||||
|
detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models))
|
||||||
|
} else {
|
||||||
|
detailText = " [#ff2a2a]Inactive / No Access[-]"
|
||||||
|
}
|
||||||
|
table.SetCell(detailRow, 0,
|
||||||
|
tview.NewTableCell(detailText).
|
||||||
|
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||||
|
SetExpansion(1).
|
||||||
|
SetSelectable(false),
|
||||||
|
)
|
||||||
|
table.SetCell(detailRow, 1,
|
||||||
|
tview.NewTableCell("[#00f0ff]"+u.Type+" ").
|
||||||
|
SetAlign(tview.AlignRight).
|
||||||
|
SetSelectable(false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if selName != "" {
|
||||||
|
for i, u := range users {
|
||||||
|
if u.Name == selName {
|
||||||
|
table.Select(i*2, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if table.GetRowCount() > 0 {
|
||||||
|
table.Select(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rebuild()
|
||||||
|
|
||||||
|
a.refreshModelCache(rebuild)
|
||||||
|
a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) }
|
||||||
|
|
||||||
|
table.SetSelectedFunc(func(row, _ int) {
|
||||||
|
visIdx := rowToVisIdx(row)
|
||||||
|
users := visibleUsers()
|
||||||
|
if visIdx < 0 || visIdx >= len(users) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uName := users[visIdx].Name
|
||||||
|
scheme := a.cfg.Provider.SchemeByName(schemeName)
|
||||||
|
if scheme == nil {
|
||||||
|
a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
|
||||||
|
})
|
||||||
|
|
||||||
|
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
row, _ := table.GetSelection()
|
||||||
|
visIdx := rowToVisIdx(row)
|
||||||
|
users := visibleUsers()
|
||||||
|
switch event.Rune() {
|
||||||
|
case 'a':
|
||||||
|
a.showUserForm(schemeName, nil, func(u tuicfg.User) {
|
||||||
|
a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
|
||||||
|
a.save()
|
||||||
|
a.refreshModelCache(rebuild)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
case 'e':
|
||||||
|
if visIdx < 0 || visIdx >= len(users) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
origName := users[visIdx].Name
|
||||||
|
orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)]
|
||||||
|
a.showUserForm(schemeName, &orig, func(u tuicfg.User) {
|
||||||
|
cfgIdx := findUserGlobalIdx(origName)
|
||||||
|
if cfgIdx < 0 {
|
||||||
|
a.showError(fmt.Sprintf("User %q no longer exists", origName))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.cfg.Provider.Users[cfgIdx] = u
|
||||||
|
a.save()
|
||||||
|
a.refreshModelCache(func() {
|
||||||
|
rebuild()
|
||||||
|
for i, usr := range visibleUsers() {
|
||||||
|
if usr.Name == u.Name {
|
||||||
|
table.Select(i*2, 0)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
case 'd':
|
||||||
|
if visIdx < 0 || visIdx >= len(users) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
uName := users[visIdx].Name
|
||||||
|
a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
|
||||||
|
cfgIdx := findUserGlobalIdx(uName)
|
||||||
|
if cfgIdx < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
all := a.cfg.Provider.Users
|
||||||
|
a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...)
|
||||||
|
a.save()
|
||||||
|
a.refreshModelCache(rebuild)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
return a.buildShell(
|
||||||
|
"users",
|
||||||
|
table,
|
||||||
|
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
|
||||||
|
name := ""
|
||||||
|
userType := "key"
|
||||||
|
key := ""
|
||||||
|
title := " ADD USER "
|
||||||
|
|
||||||
|
if existing != nil {
|
||||||
|
name = existing.Name
|
||||||
|
userType = existing.Type
|
||||||
|
key = existing.Key
|
||||||
|
title = " EDIT USER "
|
||||||
|
}
|
||||||
|
|
||||||
|
typeOptions := []string{"key", "OAuth"}
|
||||||
|
typeIdx := 0
|
||||||
|
for i, t := range typeOptions {
|
||||||
|
if t == userType {
|
||||||
|
typeIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form := tview.NewForm()
|
||||||
|
form.
|
||||||
|
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
|
||||||
|
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
|
||||||
|
AddPasswordField("Key", key, 28, '*', func(text string) { key = text }).
|
||||||
|
AddButton("SAVE", func() {
|
||||||
|
if name == "" {
|
||||||
|
a.showError("Name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
for _, u := range a.cfg.Provider.Users {
|
||||||
|
if u.Scheme == schemeName && u.Name == name {
|
||||||
|
a.showError(fmt.Sprintf("User name %q already exists for this scheme", name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.hideModal("user-form")
|
||||||
|
onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key})
|
||||||
|
}).
|
||||||
|
AddButton("CANCEL", func() {
|
||||||
|
a.hideModal("user-form")
|
||||||
|
})
|
||||||
|
|
||||||
|
form.SetBorder(true).
|
||||||
|
SetTitle(" [::b]" + title + " ").
|
||||||
|
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||||
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||||
|
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||||
|
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||||
|
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||||
|
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||||
|
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
a.hideModal("user-form")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
a.showModal("user-form", centeredForm(form, 4, 13))
|
||||||
|
}
|
||||||
64
cmd/picoclaw/dns_noresolv.go
Normal file
64
cmd/picoclaw/dns_noresolv.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// 仅在 /etc/resolv.conf 不存在时才覆盖(即 Android 环境)
|
||||||
|
if _, err := os.Stat("/etc/resolv.conf"); err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从环境变量获取 DNS server 列表,多个用 ; 隔开
|
||||||
|
// 例如: PICOCLAW_DNS_SERVER="8.8.8.8:53;1.1.1.1:53;223.5.5.5:53"
|
||||||
|
dnsEnv := os.Getenv("PICOCLAW_DNS_SERVER")
|
||||||
|
if dnsEnv == "" {
|
||||||
|
dnsEnv = "8.8.8.8:53;1.1.1.1:53"
|
||||||
|
}
|
||||||
|
|
||||||
|
var dnsServers []string
|
||||||
|
for _, s := range strings.Split(dnsEnv, ";") {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s != "" {
|
||||||
|
// 如果没有带端口号,自动补上 :53
|
||||||
|
if _, _, err := net.SplitHostPort(s); err != nil {
|
||||||
|
s = s + ":53"
|
||||||
|
}
|
||||||
|
dnsServers = append(dnsServers, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 轮询索引,在多个 DNS 服务器之间轮转
|
||||||
|
var idx uint64
|
||||||
|
|
||||||
|
customResolver := &net.Resolver{
|
||||||
|
PreferGo: true,
|
||||||
|
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
d := net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
// Round-robin: 依次尝试不同的 DNS 服务器
|
||||||
|
server := dnsServers[atomic.AddUint64(&idx, 1)%uint64(len(dnsServers))]
|
||||||
|
return d.DialContext(ctx, "udp", server)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 覆盖全局 DefaultResolver
|
||||||
|
net.DefaultResolver = customResolver
|
||||||
|
|
||||||
|
// 覆盖 http.DefaultTransport 使用自定义 DNS 解析的 DialContext
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
Resolver: customResolver,
|
||||||
|
}
|
||||||
|
|
||||||
|
if tr, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||||
|
tr.DialContext = dialer.DialContext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,16 +23,16 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
|
||||||
sessionKey = "cli:default"
|
sessionKey = "cli:default"
|
||||||
}
|
}
|
||||||
|
|
||||||
if debug {
|
|
||||||
logger.SetLevel(logger.DEBUG)
|
|
||||||
fmt.Println("🔍 Debug mode enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := internal.LoadConfig()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error loading config: %w", err)
|
return fmt.Errorf("error loading config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if debug {
|
||||||
|
logger.SetLevel(logger.DEBUG)
|
||||||
|
fmt.Println("🔍 Debug mode enabled")
|
||||||
|
}
|
||||||
|
|
||||||
if model != "" {
|
if model != "" {
|
||||||
cfg.Agents.Defaults.ModelName = model
|
cfg.Agents.Defaults.ModelName = model
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,6 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
||||||
|
|
||||||
appCfg, err := internal.LoadConfig()
|
appCfg, err := internal.LoadConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Update Providers (legacy format)
|
|
||||||
appCfg.Providers.OpenAI.AuthMethod = "oauth"
|
|
||||||
|
|
||||||
// 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 {
|
||||||
|
|
@ -71,7 +68,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
||||||
|
|
||||||
// If no openai in ModelList, add it
|
// If no openai in ModelList, add it
|
||||||
if !foundOpenAI {
|
if !foundOpenAI {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: "gpt-5.4",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.4",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
|
|
@ -130,9 +127,6 @@ func authLoginGoogleAntigravity() error {
|
||||||
|
|
||||||
appCfg, err := internal.LoadConfig()
|
appCfg, err := internal.LoadConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Update Providers (legacy format, for backward compatibility)
|
|
||||||
appCfg.Providers.Antigravity.AuthMethod = "oauth"
|
|
||||||
|
|
||||||
// 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 {
|
||||||
|
|
@ -145,7 +139,7 @@ func authLoginGoogleAntigravity() error {
|
||||||
|
|
||||||
// If no antigravity in ModelList, add it
|
// If no antigravity in ModelList, add it
|
||||||
if !foundAntigravity {
|
if !foundAntigravity {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: "gemini-flash",
|
ModelName: "gemini-flash",
|
||||||
Model: "antigravity/gemini-3-flash",
|
Model: "antigravity/gemini-3-flash",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
|
|
@ -210,8 +204,6 @@ func authLoginAnthropicSetupToken() error {
|
||||||
|
|
||||||
appCfg, err := internal.LoadConfig()
|
appCfg, err := internal.LoadConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
appCfg.Providers.Anthropic.AuthMethod = "oauth"
|
|
||||||
|
|
||||||
found := false
|
found := false
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||||
|
|
@ -221,7 +213,7 @@ func authLoginAnthropicSetupToken() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: defaultAnthropicModel,
|
ModelName: defaultAnthropicModel,
|
||||||
Model: "anthropic/" + defaultAnthropicModel,
|
Model: "anthropic/" + defaultAnthropicModel,
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
|
|
@ -287,7 +279,6 @@ func authLoginPasteToken(provider string) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
appCfg.Providers.Anthropic.AuthMethod = "token"
|
|
||||||
// Update ModelList
|
// Update ModelList
|
||||||
found := false
|
found := false
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
|
|
@ -298,7 +289,7 @@ func authLoginPasteToken(provider string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: defaultAnthropicModel,
|
ModelName: defaultAnthropicModel,
|
||||||
Model: "anthropic/" + defaultAnthropicModel,
|
Model: "anthropic/" + defaultAnthropicModel,
|
||||||
AuthMethod: "token",
|
AuthMethod: "token",
|
||||||
|
|
@ -306,7 +297,6 @@ func authLoginPasteToken(provider string) error {
|
||||||
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
|
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
|
||||||
}
|
}
|
||||||
case "openai":
|
case "openai":
|
||||||
appCfg.Providers.OpenAI.AuthMethod = "token"
|
|
||||||
// Update ModelList
|
// Update ModelList
|
||||||
found := false
|
found := false
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
|
|
@ -317,7 +307,7 @@ func authLoginPasteToken(provider string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: "gpt-5.4",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.4",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "token",
|
AuthMethod: "token",
|
||||||
|
|
@ -365,15 +355,6 @@ func authLogoutCmd(provider string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clear AuthMethod in Providers (legacy)
|
|
||||||
switch provider {
|
|
||||||
case "openai":
|
|
||||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
|
||||||
case "anthropic":
|
|
||||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
|
||||||
case "google-antigravity", "antigravity":
|
|
||||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
|
||||||
}
|
|
||||||
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -392,10 +373,6 @@ func authLogoutCmd(provider string) error {
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
appCfg.ModelList[i].AuthMethod = ""
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
}
|
}
|
||||||
// Clear all AuthMethods in Providers (legacy)
|
|
||||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
|
||||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
|
||||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
|
||||||
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import (
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/gateway"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
@ -12,6 +14,7 @@ import (
|
||||||
func NewGatewayCommand() *cobra.Command {
|
func NewGatewayCommand() *cobra.Command {
|
||||||
var debug bool
|
var debug bool
|
||||||
var noTruncate bool
|
var noTruncate bool
|
||||||
|
var allowEmpty bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "gateway",
|
Use: "gateway",
|
||||||
|
|
@ -31,12 +34,19 @@ func NewGatewayCommand() *cobra.Command {
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
return gatewayCmd(debug)
|
return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||||
cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
|
cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
|
||||||
|
cmd.Flags().BoolVarP(
|
||||||
|
&allowEmpty,
|
||||||
|
"allow-empty",
|
||||||
|
"E",
|
||||||
|
false,
|
||||||
|
"Continue starting even when no default model is configured",
|
||||||
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,5 @@ 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"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,30 +4,37 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Logo = "🦞"
|
const Logo = pkg.Logo
|
||||||
|
|
||||||
// GetPicoclawHome returns the picoclaw home directory.
|
// GetPicoclawHome returns the picoclaw home directory.
|
||||||
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||||
func GetPicoclawHome() string {
|
func GetPicoclawHome() string {
|
||||||
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
if home := os.Getenv(config.EnvHome); home != "" {
|
||||||
return home
|
return home
|
||||||
}
|
}
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
return filepath.Join(home, ".picoclaw")
|
return filepath.Join(home, pkg.DefaultPicoClawHome)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetConfigPath() string {
|
func GetConfigPath() string {
|
||||||
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
|
if configPath := os.Getenv(config.EnvConfig); configPath != "" {
|
||||||
return configPath
|
return configPath
|
||||||
}
|
}
|
||||||
return filepath.Join(GetPicoclawHome(), "config.json")
|
return filepath.Join(GetPicoclawHome(), "config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() (*config.Config, error) {
|
func LoadConfig() (*config.Config, error) {
|
||||||
return config.LoadConfig(GetConfigPath())
|
cfg, err := config.LoadConfig(GetConfigPath())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logger.SetLevelFromString(cfg.Gateway.LogLevel)
|
||||||
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatVersion returns the version string with optional git commit
|
// FormatVersion returns the version string with optional git commit
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import (
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetConfigPath(t *testing.T) {
|
func TestGetConfigPath(t *testing.T) {
|
||||||
|
|
@ -20,7 +22,7 @@ func TestGetConfigPath(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
|
func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
|
||||||
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
|
t.Setenv(config.EnvHome, "/custom/picoclaw")
|
||||||
t.Setenv("HOME", "/tmp/home")
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
got := GetConfigPath()
|
got := GetConfigPath()
|
||||||
|
|
@ -31,7 +33,7 @@ func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
|
||||||
|
|
||||||
func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
|
func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
|
||||||
t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
|
t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
|
||||||
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
|
t.Setenv(config.EnvHome, "/custom/picoclaw")
|
||||||
t.Setenv("HOME", "/tmp/home")
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
got := GetConfigPath()
|
got := GetConfigPath()
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,6 @@ Note: 'local-model' is a special value for using a local VLLM server
|
||||||
|
|
||||||
func showCurrentModel(cfg *config.Config) {
|
func showCurrentModel(cfg *config.Config) {
|
||||||
defaultModel := cfg.Agents.Defaults.ModelName
|
defaultModel := cfg.Agents.Defaults.ModelName
|
||||||
if defaultModel == "" {
|
|
||||||
defaultModel = cfg.Agents.Defaults.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
if defaultModel == "" {
|
if defaultModel == "" {
|
||||||
fmt.Println("No default model is currently set.")
|
fmt.Println("No default model is currently set.")
|
||||||
|
|
@ -78,16 +75,13 @@ func listAvailableModels(cfg *config.Config) {
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultModel := cfg.Agents.Defaults.ModelName
|
defaultModel := cfg.Agents.Defaults.ModelName
|
||||||
if defaultModel == "" {
|
|
||||||
defaultModel = cfg.Agents.Defaults.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, model := range cfg.ModelList {
|
for _, model := range cfg.ModelList {
|
||||||
marker := " "
|
marker := " "
|
||||||
if model.ModelName == defaultModel {
|
if model.ModelName == defaultModel {
|
||||||
marker = "> "
|
marker = "> "
|
||||||
}
|
}
|
||||||
if model.APIKey == "" {
|
if model.APIKey() == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
|
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
|
||||||
|
|
@ -98,7 +92,7 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er
|
||||||
// Validate that the model exists in model_list
|
// Validate that the model exists in model_list
|
||||||
modelFound := false
|
modelFound := false
|
||||||
for _, model := range cfg.ModelList {
|
for _, model := range cfg.ModelList {
|
||||||
if model.APIKey != "" && model.ModelName == modelName {
|
if model.APIKey() != "" && model.ModelName == modelName {
|
||||||
modelFound = true
|
modelFound = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -111,12 +105,8 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er
|
||||||
// Update the default model
|
// Update the default model
|
||||||
// Clear old model field and set new model_name
|
// Clear old model field and set new model_name
|
||||||
oldModel := cfg.Agents.Defaults.ModelName
|
oldModel := cfg.Agents.Defaults.ModelName
|
||||||
if oldModel == "" {
|
|
||||||
oldModel = cfg.Agents.Defaults.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Agents.Defaults.ModelName = modelName
|
cfg.Agents.Defaults.ModelName = modelName
|
||||||
cfg.Agents.Defaults.Model = "" // Clear deprecated field
|
|
||||||
|
|
||||||
// Save config back to file
|
// Save config back to file
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -58,17 +58,24 @@ func TestNewModelCommand(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
|
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||||
{ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
|
{ModelName: "claude-3", Model: "anthropic/claude-3"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"gpt-4": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
"claude-3": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
output := captureStdout(func() {
|
output := captureStdout(func() {
|
||||||
showCurrentModel(cfg)
|
showCurrentModel(cfg)
|
||||||
|
|
@ -81,17 +88,20 @@ func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "",
|
ModelName: "",
|
||||||
Model: "",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
|
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"gpt-4": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
output := captureStdout(func() {
|
output := captureStdout(func() {
|
||||||
showCurrentModel(cfg)
|
showCurrentModel(cfg)
|
||||||
|
|
@ -101,26 +111,9 @@ func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
||||||
assert.Contains(t, output, "Available models in your config:")
|
assert.Contains(t, output, "Available models in your config:")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShowCurrentModel_BackwardCompatibility(t *testing.T) {
|
|
||||||
cfg := &config.Config{
|
|
||||||
Agents: config.AgentsConfig{
|
|
||||||
Defaults: config.AgentDefaults{
|
|
||||||
Model: "legacy-model",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ModelList: []config.ModelConfig{},
|
|
||||||
}
|
|
||||||
|
|
||||||
output := captureStdout(func() {
|
|
||||||
showCurrentModel(cfg)
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Contains(t, output, "Current default model: legacy-model")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListAvailableModels_Empty(t *testing.T) {
|
func TestListAvailableModels_Empty(t *testing.T) {
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
ModelList: []config.ModelConfig{},
|
ModelList: []*config.ModelConfig{},
|
||||||
}
|
}
|
||||||
|
|
||||||
output := captureStdout(func() {
|
output := captureStdout(func() {
|
||||||
|
|
@ -131,18 +124,25 @@ func TestListAvailableModels_Empty(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListAvailableModels_WithModels(t *testing.T) {
|
func TestListAvailableModels_WithModels(t *testing.T) {
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
|
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||||
{ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
|
{ModelName: "claude-3", Model: "anthropic/claude-3"},
|
||||||
{ModelName: "no-key-model", Model: "openai/test", APIKey: ""},
|
{ModelName: "no-key-model", Model: "openai/test"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"gpt-4": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
"claude-3": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
output := captureStdout(func() {
|
output := captureStdout(func() {
|
||||||
listAvailableModels(cfg)
|
listAvailableModels(cfg)
|
||||||
|
|
@ -157,17 +157,24 @@ func TestListAvailableModels_WithModels(t *testing.T) {
|
||||||
func TestSetDefaultModel_ValidModel(t *testing.T) {
|
func TestSetDefaultModel_ValidModel(t *testing.T) {
|
||||||
initTest(t)
|
initTest(t)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "old-model",
|
ModelName: "old-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
|
{ModelName: "new-model", Model: "openai/new-model"},
|
||||||
{ModelName: "old-model", Model: "openai/old-model", APIKey: "test"},
|
{ModelName: "old-model", Model: "openai/old-model"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"new-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
"old-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
output := captureStdout(func() {
|
output := captureStdout(func() {
|
||||||
err := setDefaultModel(configPath, cfg, "new-model")
|
err := setDefaultModel(configPath, cfg, "new-model")
|
||||||
|
|
@ -180,44 +187,25 @@ func TestSetDefaultModel_ValidModel(t *testing.T) {
|
||||||
updatedCfg, err := config.LoadConfig(configPath)
|
updatedCfg, err := config.LoadConfig(configPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "new-model", updatedCfg.Agents.Defaults.ModelName)
|
assert.Equal(t, "new-model", updatedCfg.Agents.Defaults.ModelName)
|
||||||
assert.Empty(t, updatedCfg.Agents.Defaults.Model)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetDefaultModel_LegacyModelField(t *testing.T) {
|
|
||||||
initTest(t)
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
Agents: config.AgentsConfig{
|
|
||||||
Defaults: config.AgentDefaults{
|
|
||||||
Model: "legacy-old",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ModelList: []config.ModelConfig{
|
|
||||||
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
output := captureStdout(func() {
|
|
||||||
err := setDefaultModel(configPath, cfg, "new-model")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Contains(t, output, "Default model changed from 'legacy-old' to 'new-model'")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
||||||
initTest(t)
|
initTest(t)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "existing-model",
|
ModelName: "existing-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "existing-model", Model: "openai/existing", APIKey: "test"},
|
{ModelName: "existing-model", Model: "openai/existing"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"existing-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model"))
|
assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model"))
|
||||||
}
|
}
|
||||||
|
|
@ -225,17 +213,24 @@ func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
||||||
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
|
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
|
||||||
initTest(t)
|
initTest(t)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "existing-model",
|
ModelName: "existing-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "existing-model", Model: "openai/existing", APIKey: "test"},
|
{ModelName: "existing-model", Model: "openai/existing"},
|
||||||
{ModelName: "no-key-model", Model: "openai/nokey", APIKey: ""},
|
{ModelName: "no-key-model", Model: "openai/nokey"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"existing-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
"no-key-model": {
|
||||||
|
APIKeys: []string{""},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model"))
|
assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model"))
|
||||||
}
|
}
|
||||||
|
|
@ -244,16 +239,20 @@ func TestSetDefaultModel_SaveConfigError(t *testing.T) {
|
||||||
// Use an invalid path to trigger save error
|
// Use an invalid path to trigger save error
|
||||||
invalidPath := "/nonexistent/directory/config.json"
|
invalidPath := "/nonexistent/directory/config.json"
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "old-model",
|
ModelName: "old-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
|
{ModelName: "new-model", Model: "openai/new-model"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"new-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
err := setDefaultModel(invalidPath, cfg, "new-model")
|
err := setDefaultModel(invalidPath, cfg, "new-model")
|
||||||
|
|
||||||
|
|
@ -285,16 +284,20 @@ func TestModelCommandExecution_Show(t *testing.T) {
|
||||||
initTest(t)
|
initTest(t)
|
||||||
|
|
||||||
// Create a test config
|
// Create a test config
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "test-model",
|
ModelName: "test-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "test-model", Model: "openai/test", APIKey: "test"},
|
{ModelName: "test-model", Model: "openai/test"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"test-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
err := config.SaveConfig(configPath, cfg)
|
err := config.SaveConfig(configPath, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -312,17 +315,25 @@ func TestModelCommandExecution_Show(t *testing.T) {
|
||||||
func TestModelCommandExecution_Set(t *testing.T) {
|
func TestModelCommandExecution_Set(t *testing.T) {
|
||||||
initTest(t)
|
initTest(t)
|
||||||
|
|
||||||
cfg := &config.Config{
|
sec := &config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"old-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
"new-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "old-model",
|
ModelName: "old-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "old-model", Model: "openai/old", APIKey: "test"},
|
{ModelName: "old-model", Model: "openai/old"},
|
||||||
{ModelName: "new-model", Model: "openai/new", APIKey: "test"},
|
{ModelName: "new-model", Model: "openai/new"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(sec)
|
||||||
|
|
||||||
err := config.SaveConfig(configPath, cfg)
|
err := config.SaveConfig(configPath, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -346,18 +357,28 @@ func TestModelCommandExecution_TooManyArgs(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListAvailableModels_MarkerLogic(t *testing.T) {
|
func TestListAvailableModels_MarkerLogic(t *testing.T) {
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "middle-model",
|
ModelName: "middle-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "first-model", Model: "openai/first", APIKey: "test"},
|
{ModelName: "first-model", Model: "openai/first"},
|
||||||
{ModelName: "middle-model", Model: "openai/middle", APIKey: "test"},
|
{ModelName: "middle-model", Model: "openai/middle"},
|
||||||
{ModelName: "last-model", Model: "openai/last", APIKey: "test"},
|
{ModelName: "last-model", Model: "openai/last"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"first-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
"middle-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
"last-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
output := captureStdout(func() {
|
output := captureStdout(func() {
|
||||||
listAvailableModels(cfg)
|
listAvailableModels(cfg)
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,27 @@ import (
|
||||||
var embeddedFiles embed.FS
|
var embeddedFiles embed.FS
|
||||||
|
|
||||||
func NewOnboardCommand() *cobra.Command {
|
func NewOnboardCommand() *cobra.Command {
|
||||||
|
var encrypt bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "onboard",
|
Use: "onboard",
|
||||||
Aliases: []string{"o"},
|
Aliases: []string{"o"},
|
||||||
Short: "Initialize picoclaw configuration and workspace",
|
Short: "Initialize picoclaw configuration, workspace, and channel accounts",
|
||||||
|
// Run without subcommands → original onboard flow
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
onboard()
|
if len(args) == 0 {
|
||||||
|
onboard(encrypt)
|
||||||
|
} else {
|
||||||
|
_ = cmd.Help()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVar(&encrypt, "enc", false,
|
||||||
|
"Enable credential encryption (generates SSH key and prompts for passphrase)")
|
||||||
|
|
||||||
|
// Channel onboarding subcommands
|
||||||
|
cmd.AddCommand(newWeixinCommand())
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ func TestNewOnboardCommand(t *testing.T) {
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
assert.Equal(t, "onboard", cmd.Use)
|
assert.Equal(t, "onboard", cmd.Use)
|
||||||
assert.Equal(t, "Initialize picoclaw configuration and workspace", cmd.Short)
|
assert.Equal(t, "Initialize picoclaw configuration, workspace, and channel accounts", cmd.Short)
|
||||||
|
|
||||||
assert.Len(t, cmd.Aliases, 1)
|
assert.Len(t, cmd.Aliases, 1)
|
||||||
assert.True(t, cmd.HasAlias("o"))
|
assert.True(t, cmd.HasAlias("o"))
|
||||||
|
|
@ -24,6 +24,10 @@ func TestNewOnboardCommand(t *testing.T) {
|
||||||
assert.Nil(t, cmd.PersistentPreRun)
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
assert.Nil(t, cmd.PersistentPostRun)
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
|
||||||
assert.False(t, cmd.HasFlags())
|
assert.True(t, cmd.HasFlags())
|
||||||
assert.False(t, cmd.HasSubCommands())
|
encFlag := cmd.Flags().Lookup("enc")
|
||||||
|
require.NotNil(t, encFlag, "expected --enc flag to be registered")
|
||||||
|
assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false")
|
||||||
|
assert.True(t, cmd.HasSubCommands())
|
||||||
|
assert.NotNil(t, cmd.Commands())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,71 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
|
||||||
"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/credential"
|
||||||
)
|
)
|
||||||
|
|
||||||
func onboard() {
|
func onboard(encrypt bool) {
|
||||||
configPath := internal.GetConfigPath()
|
configPath := internal.GetConfigPath()
|
||||||
|
|
||||||
|
configExists := false
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
|
configExists = true
|
||||||
|
if encrypt {
|
||||||
|
// Only ask for confirmation when *both* config and SSH key already exist,
|
||||||
|
// indicating a full re-onboard that would reset the config to defaults.
|
||||||
|
sshKeyPath, _ := credential.DefaultSSHKeyPath()
|
||||||
|
if _, err := os.Stat(sshKeyPath); err == nil {
|
||||||
|
// Both exist — confirm a full reset.
|
||||||
fmt.Printf("Config already exists at %s\n", configPath)
|
fmt.Printf("Config already exists at %s\n", configPath)
|
||||||
fmt.Print("Overwrite? (y/n): ")
|
fmt.Print("Overwrite config with defaults? (y/n): ")
|
||||||
var response string
|
var response string
|
||||||
fmt.Scanln(&response)
|
fmt.Scanln(&response)
|
||||||
if response != "y" {
|
if response != "y" {
|
||||||
fmt.Println("Aborted.")
|
fmt.Println("Aborted.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
configExists = false // user agreed to reset; treat as fresh
|
||||||
|
}
|
||||||
|
// Config exists but SSH key is missing — keep existing config, only add SSH key.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.DefaultConfig()
|
var err error
|
||||||
|
if encrypt {
|
||||||
|
fmt.Println("\nSet up credential encryption")
|
||||||
|
fmt.Println("-----------------------------")
|
||||||
|
passphrase, pErr := promptPassphrase()
|
||||||
|
if pErr != nil {
|
||||||
|
fmt.Printf("Error: %v\n", pErr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// Expose the passphrase to credential.PassphraseProvider (which calls
|
||||||
|
// os.Getenv by default) so that SaveConfig can encrypt api_keys.
|
||||||
|
// This process is a one-shot CLI tool; the env var is never exposed outside
|
||||||
|
// the current process and disappears when it exits.
|
||||||
|
os.Setenv(credential.PassphraseEnvVar, passphrase)
|
||||||
|
|
||||||
|
if err = setupSSHKey(); err != nil {
|
||||||
|
fmt.Printf("Error generating SSH key: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg *config.Config
|
||||||
|
if configExists {
|
||||||
|
// Preserve the existing config; SaveConfig will re-encrypt api_keys with the new passphrase.
|
||||||
|
cfg, err = config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error loading existing config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cfg = config.DefaultConfig()
|
||||||
|
}
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
fmt.Printf("Error saving config: %v\n", err)
|
fmt.Printf("Error saving config: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
@ -33,9 +79,17 @@ func onboard() {
|
||||||
workspace := cfg.WorkspacePath()
|
workspace := cfg.WorkspacePath()
|
||||||
createWorkspaceTemplates(workspace)
|
createWorkspaceTemplates(workspace)
|
||||||
|
|
||||||
fmt.Printf("%s picoclaw is ready!\n", internal.Logo)
|
fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo)
|
||||||
fmt.Println("\nNext steps:")
|
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(" 1. Add your API key to", configPath)
|
||||||
|
}
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println(" Recommended:")
|
fmt.Println(" Recommended:")
|
||||||
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
|
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
|
||||||
|
|
@ -43,7 +97,62 @@ func onboard() {
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println(" See README.md for 17+ supported providers.")
|
fmt.Println(" See README.md for 17+ supported providers.")
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptPassphrase reads the encryption passphrase twice from the terminal
|
||||||
|
// (with echo disabled) and returns it. Returns an error if the passphrase is
|
||||||
|
// empty or if the two inputs do not match.
|
||||||
|
func promptPassphrase() (string, error) {
|
||||||
|
fmt.Print("Enter passphrase for credential encryption: ")
|
||||||
|
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading passphrase: %w", err)
|
||||||
|
}
|
||||||
|
if len(p1) == 0 {
|
||||||
|
return "", fmt.Errorf("passphrase must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Confirm passphrase: ")
|
||||||
|
p2, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading passphrase confirmation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(p1) != string(p2) {
|
||||||
|
return "", fmt.Errorf("passphrases do not match")
|
||||||
|
}
|
||||||
|
return string(p1), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key.
|
||||||
|
// If the key already exists the user is warned and asked to confirm overwrite.
|
||||||
|
// Answering anything other than "y" keeps the existing key (not an error).
|
||||||
|
func setupSSHKey() error {
|
||||||
|
keyPath, err := credential.DefaultSSHKeyPath()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot determine SSH key path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(keyPath); err == nil {
|
||||||
|
fmt.Printf("\n⚠️ WARNING: %s already exists.\n", keyPath)
|
||||||
|
fmt.Println(" Overwriting will invalidate any credentials previously encrypted with this key.")
|
||||||
|
fmt.Print(" Overwrite? (y/n): ")
|
||||||
|
var response string
|
||||||
|
fmt.Scanln(&response)
|
||||||
|
if response != "y" {
|
||||||
|
fmt.Println("Keeping existing SSH key.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := credential.GenerateSSHKey(keyPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("SSH key generated: %s\n", keyPath)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createWorkspaceTemplates(workspace string) {
|
func createWorkspaceTemplates(workspace string) {
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,32 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCopyEmbeddedToTargetUsesAgentsMarkdown(t *testing.T) {
|
func TestCopyEmbeddedToTargetUsesStructuredAgentFiles(t *testing.T) {
|
||||||
targetDir := t.TempDir()
|
targetDir := t.TempDir()
|
||||||
|
|
||||||
if err := copyEmbeddedToTarget(targetDir); err != nil {
|
if err := copyEmbeddedToTarget(targetDir); err != nil {
|
||||||
t.Fatalf("copyEmbeddedToTarget() error = %v", err)
|
t.Fatalf("copyEmbeddedToTarget() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
agentsPath := filepath.Join(targetDir, "AGENTS.md")
|
agentPath := filepath.Join(targetDir, "AGENT.md")
|
||||||
if _, err := os.Stat(agentsPath); err != nil {
|
if _, err := os.Stat(agentPath); err != nil {
|
||||||
t.Fatalf("expected %s to exist: %v", agentsPath, err)
|
t.Fatalf("expected %s to exist: %v", agentPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
legacyPath := filepath.Join(targetDir, "AGENT.md")
|
soulPath := filepath.Join(targetDir, "SOUL.md")
|
||||||
|
if _, err := os.Stat(soulPath); err != nil {
|
||||||
|
t.Fatalf("expected %s to exist: %v", soulPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userPath := filepath.Join(targetDir, "USER.md")
|
||||||
|
if _, err := os.Stat(userPath); err != nil {
|
||||||
|
t.Fatalf("expected %s to exist: %v", userPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, legacyName := range []string{"AGENTS.md", "IDENTITY.md"} {
|
||||||
|
legacyPath := filepath.Join(targetDir, legacyName)
|
||||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||||
t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err)
|
t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
124
cmd/picoclaw/internal/onboard/weixin.go
Normal file
124
cmd/picoclaw/internal/onboard/weixin.go
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
package onboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels/weixin"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newWeixinCommand() *cobra.Command {
|
||||||
|
var baseURL string
|
||||||
|
var proxy string
|
||||||
|
var timeout int
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "weixin",
|
||||||
|
Short: "Connect a WeChat personal account via QR code",
|
||||||
|
Long: `Start the interactive Weixin (WeChat personal) QR code login flow.
|
||||||
|
|
||||||
|
A QR code is displayed in the terminal. Scan it with the WeChat mobile app
|
||||||
|
to authorize your account. On success, the bot token is saved to the picoclaw
|
||||||
|
config so you can start the gateway immediately.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
picoclaw onboard weixin`,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return runWeixinOnboard(baseURL, proxy, time.Duration(timeout)*time.Second)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&baseURL, "base-url", "https://ilinkai.weixin.qq.com/", "iLink API base URL")
|
||||||
|
cmd.Flags().StringVar(&proxy, "proxy", "", "HTTP proxy URL (e.g. http://localhost:7890)")
|
||||||
|
cmd.Flags().IntVar(&timeout, "timeout", 300, "Login timeout in seconds")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWeixinOnboard(baseURL, proxy string, timeout time.Duration) error {
|
||||||
|
fmt.Println("Starting Weixin (WeChat personal) login...")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
botToken, userID, accountID, returnedBaseURL, err := weixin.PerformLoginInteractive(
|
||||||
|
context.Background(),
|
||||||
|
weixin.AuthFlowOpts{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
Timeout: timeout,
|
||||||
|
Proxy: proxy,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("login failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("✅ Login successful!")
|
||||||
|
fmt.Printf(" Account ID : %s\n", accountID)
|
||||||
|
if userID != "" {
|
||||||
|
fmt.Printf(" User ID : %s\n", userID)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Prefer the server-returned base URL (may be region-specific)
|
||||||
|
effectiveBaseURL := returnedBaseURL
|
||||||
|
if effectiveBaseURL == "" {
|
||||||
|
effectiveBaseURL = baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveWeixinConfig(botToken, effectiveBaseURL, proxy); err != nil {
|
||||||
|
fmt.Printf("⚠️ Could not auto-save to config: %v\n", err)
|
||||||
|
printManualWeixinConfig(botToken, effectiveBaseURL)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✓ Config updated. Start the gateway with:")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" picoclaw gateway")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("To restrict which WeChat users can send messages, add their user IDs")
|
||||||
|
fmt.Println("to channels.weixin.allow_from in your config.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveWeixinConfig patches channels.weixin in the config and saves it.
|
||||||
|
func saveWeixinConfig(token, baseURL, proxy string) error {
|
||||||
|
cfgPath := internal.GetConfigPath()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Channels.Weixin.Enabled = true
|
||||||
|
cfg.Channels.Weixin.SetToken(token)
|
||||||
|
const defaultBase = "https://ilinkai.weixin.qq.com/"
|
||||||
|
if baseURL != "" && baseURL != defaultBase {
|
||||||
|
cfg.Channels.Weixin.BaseURL = baseURL
|
||||||
|
}
|
||||||
|
if proxy != "" {
|
||||||
|
cfg.Channels.Weixin.Proxy = proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.SaveConfig(cfgPath, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printManualWeixinConfig(token, baseURL string) {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Add the following to the channels section of your picoclaw config:")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(` "weixin": {`)
|
||||||
|
fmt.Println(` "enabled": true,`)
|
||||||
|
fmt.Printf(" \"token\": %q,\n", token)
|
||||||
|
const defaultBase = "https://ilinkai.weixin.qq.com/"
|
||||||
|
if baseURL != "" && baseURL != defaultBase {
|
||||||
|
fmt.Printf(" \"base_url\": %q,\n", baseURL)
|
||||||
|
}
|
||||||
|
fmt.Println(` "allow_from": []`)
|
||||||
|
fmt.Println(` }`)
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,7 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
d.workspace = cfg.WorkspacePath()
|
d.workspace = cfg.WorkspacePath()
|
||||||
installer, err := skills.NewSkillInstaller(
|
installer, err := skills.NewSkillInstaller(
|
||||||
d.workspace,
|
d.workspace,
|
||||||
cfg.Tools.Skills.Github.Token,
|
cfg.Tools.Skills.Github.Token(),
|
||||||
cfg.Tools.Skills.Github.Proxy,
|
cfg.Tools.Skills.Github.Proxy,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -64,9 +64,20 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
||||||
|
|
||||||
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
||||||
|
|
||||||
|
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
ClawHub: skills.ClawHubConfig{
|
||||||
|
Enabled: clawHubConfig.Enabled,
|
||||||
|
BaseURL: clawHubConfig.BaseURL,
|
||||||
|
AuthToken: clawHubConfig.AuthToken(),
|
||||||
|
SearchPath: clawHubConfig.SearchPath,
|
||||||
|
SkillsPath: clawHubConfig.SkillsPath,
|
||||||
|
DownloadPath: clawHubConfig.DownloadPath,
|
||||||
|
Timeout: clawHubConfig.Timeout,
|
||||||
|
MaxZipSize: clawHubConfig.MaxZipSize,
|
||||||
|
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
registry := registryMgr.GetRegistry(registryName)
|
registry := registryMgr.GetRegistry(registryName)
|
||||||
|
|
@ -226,9 +237,20 @@ func skillsSearchCmd(query string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
ClawHub: skills.ClawHubConfig{
|
||||||
|
Enabled: clawHubConfig.Enabled,
|
||||||
|
BaseURL: clawHubConfig.BaseURL,
|
||||||
|
AuthToken: clawHubConfig.AuthToken(),
|
||||||
|
SearchPath: clawHubConfig.SearchPath,
|
||||||
|
SkillsPath: clawHubConfig.SkillsPath,
|
||||||
|
DownloadPath: clawHubConfig.DownloadPath,
|
||||||
|
Timeout: clawHubConfig.Timeout,
|
||||||
|
MaxZipSize: clawHubConfig.MaxZipSize,
|
||||||
|
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
|
|
||||||
|
|
@ -42,48 +42,6 @@ func statusCmd() {
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
|
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
|
||||||
|
|
||||||
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
|
|
||||||
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
|
|
||||||
hasOpenAI := cfg.Providers.OpenAI.APIKey != ""
|
|
||||||
hasGemini := cfg.Providers.Gemini.APIKey != ""
|
|
||||||
hasZhipu := cfg.Providers.Zhipu.APIKey != ""
|
|
||||||
hasQwen := cfg.Providers.Qwen.APIKey != ""
|
|
||||||
hasGroq := cfg.Providers.Groq.APIKey != ""
|
|
||||||
hasVLLM := cfg.Providers.VLLM.APIBase != ""
|
|
||||||
hasMoonshot := cfg.Providers.Moonshot.APIKey != ""
|
|
||||||
hasDeepSeek := cfg.Providers.DeepSeek.APIKey != ""
|
|
||||||
hasVolcEngine := cfg.Providers.VolcEngine.APIKey != ""
|
|
||||||
hasNvidia := cfg.Providers.Nvidia.APIKey != ""
|
|
||||||
hasOllama := cfg.Providers.Ollama.APIBase != ""
|
|
||||||
|
|
||||||
status := func(enabled bool) string {
|
|
||||||
if enabled {
|
|
||||||
return "✓"
|
|
||||||
}
|
|
||||||
return "not set"
|
|
||||||
}
|
|
||||||
fmt.Println("OpenRouter API:", status(hasOpenRouter))
|
|
||||||
fmt.Println("Anthropic API:", status(hasAnthropic))
|
|
||||||
fmt.Println("OpenAI API:", status(hasOpenAI))
|
|
||||||
fmt.Println("Gemini API:", status(hasGemini))
|
|
||||||
fmt.Println("Zhipu API:", status(hasZhipu))
|
|
||||||
fmt.Println("Qwen API:", status(hasQwen))
|
|
||||||
fmt.Println("Groq API:", status(hasGroq))
|
|
||||||
fmt.Println("Moonshot API:", status(hasMoonshot))
|
|
||||||
fmt.Println("DeepSeek API:", status(hasDeepSeek))
|
|
||||||
fmt.Println("VolcEngine API:", status(hasVolcEngine))
|
|
||||||
fmt.Println("Nvidia API:", status(hasNvidia))
|
|
||||||
if hasVLLM {
|
|
||||||
fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase)
|
|
||||||
} else {
|
|
||||||
fmt.Println("vLLM/Local: not set")
|
|
||||||
}
|
|
||||||
if hasOllama {
|
|
||||||
fmt.Printf("Ollama: ✓ %s\n", cfg.Providers.Ollama.APIBase)
|
|
||||||
} else {
|
|
||||||
fmt.Println("Ollama: not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
store, _ := auth.LoadStore()
|
store, _ := auth.LoadStore()
|
||||||
if store != nil && len(store.Credentials) > 0 {
|
if store != nil && len(store.Credentials) > 0 {
|
||||||
fmt.Println("\nOAuth/Token Auth:")
|
fmt.Println("\nOAuth/Token Auth:")
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,15 @@
|
||||||
"restrict_to_workspace": true,
|
"restrict_to_workspace": true,
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
|
"context_window": 131072,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20,
|
"max_tool_iterations": 20,
|
||||||
"summarize_message_threshold": 20,
|
"summarize_message_threshold": 20,
|
||||||
"summarize_token_percent": 75
|
"summarize_token_percent": 75,
|
||||||
|
"tool_feedback": {
|
||||||
|
"enabled": false,
|
||||||
|
"max_args_length": 300
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"model_list": [
|
"model_list": [
|
||||||
|
|
@ -78,10 +83,12 @@
|
||||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||||
"base_url": "",
|
"base_url": "",
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
"allow_from": [
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
"YOUR_USER_ID"
|
"use_markdown_v2": false,
|
||||||
],
|
"reasoning_channel_id": "",
|
||||||
"reasoning_channel_id": ""
|
"streaming": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
@ -123,7 +130,8 @@
|
||||||
"verification_token": "",
|
"verification_token": "",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": "",
|
||||||
"random_reaction_emoji": []
|
"random_reaction_emoji": [],
|
||||||
|
"is_lark": false
|
||||||
},
|
},
|
||||||
"dingtalk": {
|
"dingtalk": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
@ -200,6 +208,8 @@
|
||||||
"wecom_aibot": {
|
"wecom_aibot": {
|
||||||
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
|
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
"bot_id": "YOUR_BOT_ID",
|
||||||
|
"secret": "YOUR_SECRET",
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
"webhook_path": "/webhook/wecom-aibot",
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
|
@ -207,6 +217,25 @@
|
||||||
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
|
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
|
"pico": {
|
||||||
|
"enabled": false,
|
||||||
|
"token": "YOUR_PICO_TOKEN",
|
||||||
|
"allow_token_query": false,
|
||||||
|
"allow_origins": [],
|
||||||
|
"ping_interval": 30,
|
||||||
|
"read_timeout": 60,
|
||||||
|
"max_connections": 100,
|
||||||
|
"allow_from": []
|
||||||
|
},
|
||||||
|
"pico_client": {
|
||||||
|
"enabled": false,
|
||||||
|
"url": "wss://remote-pico-server/pico/ws",
|
||||||
|
"token": "YOUR_PICO_TOKEN",
|
||||||
|
"session_id": "",
|
||||||
|
"ping_interval": 30,
|
||||||
|
"read_timeout": 60,
|
||||||
|
"allow_from": []
|
||||||
|
},
|
||||||
"irc": {
|
"irc": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"server": "irc.libera.chat:6697",
|
"server": "irc.libera.chat:6697",
|
||||||
|
|
@ -313,6 +342,9 @@
|
||||||
"allow_write_paths": null,
|
"allow_write_paths": null,
|
||||||
"web": {
|
"web": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"prefer_native": true,
|
||||||
|
"fetch_limit_bytes": 10485760,
|
||||||
|
"format": "plaintext",
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
|
@ -351,7 +383,8 @@
|
||||||
"search_engine": "search_std",
|
"search_engine": "search_std",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"fetch_limit_bytes": 10485760
|
"fetch_limit_bytes": 10485760,
|
||||||
|
"private_host_whitelist": []
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
@ -514,10 +547,22 @@
|
||||||
"monitor_usb": true
|
"monitor_usb": true
|
||||||
},
|
},
|
||||||
"voice": {
|
"voice": {
|
||||||
|
"model_name": "",
|
||||||
"echo_transcription": false
|
"echo_transcription": false
|
||||||
},
|
},
|
||||||
|
"hooks": {
|
||||||
|
"enabled": true,
|
||||||
|
"defaults": {
|
||||||
|
"observer_timeout_ms": 500,
|
||||||
|
"interceptor_timeout_ms": 5000,
|
||||||
|
"approval_timeout_ms": 60000
|
||||||
|
}
|
||||||
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
|
"_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 18790
|
"port": 18790,
|
||||||
|
"hot_reload": false,
|
||||||
|
"log_level": "fatal"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
67
docker/Dockerfile.heavy
Normal file
67
docker/Dockerfile.heavy
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# ============================================================
|
||||||
|
# Stage 1: Build the picoclaw binary
|
||||||
|
# ============================================================
|
||||||
|
FROM golang:1.26.0-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git make
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Cache dependencies
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
RUN make build
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Stage 2: Node.js runtime with Python + MCP support
|
||||||
|
# ============================================================
|
||||||
|
FROM node:24-alpine3.23
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
python3 \
|
||||||
|
py3-pip \
|
||||||
|
chromium \
|
||||||
|
jq
|
||||||
|
|
||||||
|
# Install Playwright browsers for agent-browser
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers
|
||||||
|
RUN npm install -g agent-browser && \
|
||||||
|
npx playwright install chromium && \
|
||||||
|
chmod -R o+rx $PLAYWRIGHT_BROWSERS_PATH
|
||||||
|
|
||||||
|
# Install uv
|
||||||
|
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||||
|
ln -s /root/.local/bin/uv /usr/local/bin/uv && \
|
||||||
|
ln -s /root/.local/bin/uvx /usr/local/bin/uvx && \
|
||||||
|
uv --version
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -q --spider http://localhost:18790/health || exit 1
|
||||||
|
|
||||||
|
# Copy binary
|
||||||
|
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||||
|
|
||||||
|
# Reuse existing node user (UID/GID 1000) — rename to picoclaw
|
||||||
|
RUN deluser node 2>/dev/null; delgroup node 2>/dev/null; \
|
||||||
|
addgroup -g 1000 picoclaw 2>/dev/null; \
|
||||||
|
adduser -D -u 1000 -G picoclaw -h /home/picoclaw picoclaw 2>/dev/null || true
|
||||||
|
|
||||||
|
USER picoclaw
|
||||||
|
|
||||||
|
# Run onboard to create initial directories and config
|
||||||
|
RUN /usr/local/bin/picoclaw onboard
|
||||||
|
|
||||||
|
# Copy default workspace
|
||||||
|
COPY --chown=picoclaw:picoclaw workspace/ /home/picoclaw/.picoclaw/workspace/
|
||||||
|
|
||||||
|
VOLUME /home/picoclaw/.picoclaw/workspace
|
||||||
|
|
||||||
|
ENTRYPOINT ["picoclaw"]
|
||||||
|
CMD ["gateway"]
|
||||||
|
|
@ -438,7 +438,7 @@ type ProviderAuthResult = {
|
||||||
|
|
||||||
### 1. Required Environment/Dependencies
|
### 1. Required Environment/Dependencies
|
||||||
|
|
||||||
- Go ≥ 1.21
|
- Go ≥ 1.25
|
||||||
- PicoClaw codebase (`pkg/providers/` and `pkg/auth/`)
|
- PicoClaw codebase (`pkg/providers/` and `pkg/auth/`)
|
||||||
- `crypto` and `net/http` standard library packages
|
- `crypto` and `net/http` standard library packages
|
||||||
|
|
||||||
|
|
@ -584,7 +584,7 @@ Each SSE message (`data: {...}`) is wrapped in a `response` field:
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gemini-flash"
|
"model_name": "gemini-flash"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -674,7 +674,7 @@ Add a default entry in `pkg/config/defaults.go`:
|
||||||
|
|
||||||
#### 5. Add Auth Support (Optional)
|
#### 5. Add Auth Support (Optional)
|
||||||
|
|
||||||
If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/cmd_auth.go`:
|
If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/internal/auth/helpers.go`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
case "your-provider":
|
case "your-provider":
|
||||||
|
|
@ -736,7 +736,7 @@ export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/m
|
||||||
- `pkg/auth/store.go` - Auth credential storage (`~/.picoclaw/auth.json`)
|
- `pkg/auth/store.go` - Auth credential storage (`~/.picoclaw/auth.json`)
|
||||||
- `pkg/providers/factory.go` - Provider factory and protocol routing
|
- `pkg/providers/factory.go` - Provider factory and protocol routing
|
||||||
- `pkg/providers/types.go` - Provider interface definitions
|
- `pkg/providers/types.go` - Provider interface definitions
|
||||||
- `cmd/picoclaw/cmd_auth.go` - Auth CLI commands
|
- `cmd/picoclaw/internal/auth/helpers.go` - Auth CLI commands
|
||||||
|
|
||||||
- **Documentation:**
|
- **Documentation:**
|
||||||
- `docs/ANTIGRAVITY_USAGE.md` - Antigravity usage guide
|
- `docs/ANTIGRAVITY_USAGE.md` - Antigravity usage guide
|
||||||
|
|
|
||||||
164
docs/agent-refactor/context.md
Normal file
164
docs/agent-refactor/context.md
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
# Context
|
||||||
|
|
||||||
|
## What this document covers
|
||||||
|
|
||||||
|
This document makes explicit the boundaries of context management in the agent loop:
|
||||||
|
|
||||||
|
- what fills the context window and how space is divided
|
||||||
|
- what is stored in session history vs. built at request time
|
||||||
|
- when and how context compression happens
|
||||||
|
- how token budgets are estimated
|
||||||
|
|
||||||
|
These are existing concepts. This document clarifies their boundaries rather than introducing new ones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context window regions
|
||||||
|
|
||||||
|
The context window is the model's total input capacity. Four regions fill it:
|
||||||
|
|
||||||
|
| Region | Assembled by | Stored in session? |
|
||||||
|
|---|---|---|
|
||||||
|
| System prompt | `BuildMessages()` — static + dynamic parts | No |
|
||||||
|
| Summary | `SetSummary()` stores it; `BuildMessages()` injects it | Separate from history |
|
||||||
|
| Session history | User / assistant / tool messages | Yes |
|
||||||
|
| Tool definitions | Provider adapter injects at call time | No |
|
||||||
|
|
||||||
|
`MaxTokens` (the output generation limit) must also be reserved from the total budget.
|
||||||
|
|
||||||
|
The available space for history is therefore:
|
||||||
|
|
||||||
|
```
|
||||||
|
history_budget = ContextWindow - system_prompt - summary - tool_definitions - MaxTokens
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ContextWindow vs MaxTokens
|
||||||
|
|
||||||
|
These serve different purposes:
|
||||||
|
|
||||||
|
- **MaxTokens** — maximum tokens the LLM may generate in one response. Sent as the `max_tokens` request parameter.
|
||||||
|
- **ContextWindow** — the model's total input context capacity.
|
||||||
|
|
||||||
|
These were previously set to the same value, which caused the summarization threshold to fire either far too early (at the default 32K) or not at all (when a user raised `max_tokens`).
|
||||||
|
|
||||||
|
Current default when not explicitly configured: `ContextWindow = MaxTokens * 4`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session history
|
||||||
|
|
||||||
|
Session history stores only conversation messages:
|
||||||
|
|
||||||
|
- `user` — user input
|
||||||
|
- `assistant` — LLM response (may include `ToolCalls`)
|
||||||
|
- `tool` — tool execution results
|
||||||
|
|
||||||
|
Session history does **not** contain:
|
||||||
|
|
||||||
|
- System prompts — assembled at request time by `BuildMessages`
|
||||||
|
- Summary content — stored separately via `SetSummary`, injected by `BuildMessages`
|
||||||
|
|
||||||
|
This distinction matters: any code that operates on session history — compression, boundary detection, token estimation — must not assume a system message is present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Turn
|
||||||
|
|
||||||
|
A **Turn** is one complete cycle:
|
||||||
|
|
||||||
|
> user message -> LLM iterations (possibly including tool calls) -> final assistant response
|
||||||
|
|
||||||
|
This definition comes from the agent loop design (#1316). In session history, Turn boundaries are identified by `user`-role messages.
|
||||||
|
|
||||||
|
Turn is the atomic unit for compression. Cutting inside a Turn can orphan tool-call sequences — an assistant message with `ToolCalls` separated from its corresponding `tool` results. Compressing at Turn boundaries avoids this by construction.
|
||||||
|
|
||||||
|
`parseTurnBoundaries(history)` returns the starting index of each Turn.
|
||||||
|
`findSafeBoundary(history, targetIndex)` snaps a target cut point to the nearest Turn boundary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compression paths
|
||||||
|
|
||||||
|
Three compression paths exist, in order of preference:
|
||||||
|
|
||||||
|
### 1. Async summarization
|
||||||
|
|
||||||
|
`maybeSummarize` runs after each Turn completes.
|
||||||
|
|
||||||
|
Triggers when message count exceeds a threshold, or when estimated history tokens exceed a percentage of `ContextWindow`. If triggered, a background goroutine calls the LLM to produce a summary of the oldest messages. The summary is stored via `SetSummary`; `BuildMessages` injects it into the system prompt on the next call.
|
||||||
|
|
||||||
|
Cut point uses `findSafeBoundary` so no Turn is split.
|
||||||
|
|
||||||
|
### 2. Proactive budget check
|
||||||
|
|
||||||
|
`isOverContextBudget` runs before each LLM call.
|
||||||
|
|
||||||
|
Uses the full budget formula: `message_tokens + tool_def_tokens + MaxTokens > ContextWindow`. If over budget, triggers `forceCompression` and rebuilds messages before calling the LLM.
|
||||||
|
|
||||||
|
This prevents wasted (and billed) LLM calls that would otherwise fail with a context-window error.
|
||||||
|
|
||||||
|
### 3. Emergency compression (reactive)
|
||||||
|
|
||||||
|
`forceCompression` runs when the LLM returns a context-window error despite the proactive check.
|
||||||
|
|
||||||
|
Drops the oldest ~50% of Turns. If the history is a single Turn with no safe split point (e.g. one user message followed by a massive tool response), falls back to keeping only the most recent user message — breaking Turn atomicity as a last resort to avoid a context-exceeded loop.
|
||||||
|
|
||||||
|
Stores a compression note in the session summary (not in history messages) so `BuildMessages` can include it in the next system prompt.
|
||||||
|
|
||||||
|
This is the fallback for when the token estimate undershoots reality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Token estimation
|
||||||
|
|
||||||
|
Estimation uses a heuristic of ~2.5 characters per token (`chars * 2 / 5`).
|
||||||
|
|
||||||
|
`estimateMessageTokens` counts:
|
||||||
|
|
||||||
|
- `Content` (rune count, for multibyte correctness)
|
||||||
|
- `ReasoningContent` (extended thinking / chain-of-thought)
|
||||||
|
- `ToolCalls` — ID, type, function name, arguments
|
||||||
|
- `ToolCallID` (tool result metadata)
|
||||||
|
- Per-message overhead (role label, JSON structure)
|
||||||
|
- `Media` items — flat per-item token estimate, added directly to the final count (not through the character heuristic, since actual cost depends on resolution and provider-specific image tokenization)
|
||||||
|
|
||||||
|
`estimateToolDefsTokens` counts tool definition overhead: name, description, JSON schema of parameters.
|
||||||
|
|
||||||
|
These are deliberately heuristic. The proactive check handles the common case; the reactive path catches estimation errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interface boundaries
|
||||||
|
|
||||||
|
Context budget functions (`parseTurnBoundaries`, `findSafeBoundary`, `estimateMessageTokens`, `isOverContextBudget`) are **pure functions**. They take `[]providers.Message` and integer parameters. They have no dependency on `AgentLoop` or any other runtime struct.
|
||||||
|
|
||||||
|
`BuildMessages` is the sole assembler of the final message array sent to the LLM. Budget functions inform compression decisions but do not construct messages.
|
||||||
|
|
||||||
|
`forceCompression` and `summarizeSession` mutate session state (history and summary). `BuildMessages` reads that state to construct context. The flow is:
|
||||||
|
|
||||||
|
```
|
||||||
|
budget check --> compression decision --> mutate session --> BuildMessages reads session --> LLM call
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
These are recognized limitations in the current implementation, documented here for visibility:
|
||||||
|
|
||||||
|
- **Summarization trigger does not use the full budget formula.** `maybeSummarize` compares estimated history tokens against a percentage of `ContextWindow`. It does not account for system prompt size, tool definition overhead, or `MaxTokens` reserve. The proactive check covers the critical path (preventing 400 errors), but the summarization trigger could be aligned with the same budget model for more accurate early compression.
|
||||||
|
|
||||||
|
- **Token estimation is heuristic.** It does not account for provider-specific tokenization, exact system prompt size (assembled separately), or variable image token costs. The two-path design (proactive + reactive) is intended to tolerate this imprecision.
|
||||||
|
|
||||||
|
- **Reactive retry does not preserve media.** When the reactive path rebuilds context after compression, it currently passes empty values for media references. This is a pre-existing issue in the main loop, not introduced by the budget system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this document does not cover
|
||||||
|
|
||||||
|
- How `AGENT.md` frontmatter configures context parameters — that is part of the Agent definition work
|
||||||
|
- How the context builder assembles context in the new architecture — that is upcoming work
|
||||||
|
- How compression events surface through the event system — that is part of the event model (#1316)
|
||||||
|
- Subagent context isolation — that is a separate track
|
||||||
35
docs/channels/dingtalk/README.fr.md
Normal file
35
docs/channels/dingtalk/README.fr.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
> Retour au [README](../../../README.fr.md)
|
||||||
|
|
||||||
|
# DingTalk
|
||||||
|
|
||||||
|
DingTalk est la plateforme de communication d'entreprise d'Alibaba, très populaire dans les milieux professionnels chinois. Elle utilise un SDK de streaming pour maintenir des connexions persistantes.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"dingtalk": {
|
||||||
|
"enabled": true,
|
||||||
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Champ | Type | Requis | Description |
|
||||||
|
| ------------- | ------ | ------ | ---------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Oui | Activer ou non le canal DingTalk |
|
||||||
|
| client_id | string | Oui | Client ID de l'application DingTalk |
|
||||||
|
| client_secret | string | Oui | Client Secret de l'application DingTalk |
|
||||||
|
| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs |
|
||||||
|
|
||||||
|
## Procédure de configuration
|
||||||
|
|
||||||
|
1. Rendez-vous sur la [plateforme ouverte DingTalk](https://open.dingtalk.com/)
|
||||||
|
2. Créez une application interne d'entreprise
|
||||||
|
3. Obtenez le Client ID et le Client Secret depuis les paramètres de l'application
|
||||||
|
4. Configurez OAuth et les abonnements aux événements (si nécessaire)
|
||||||
|
5. Renseignez le Client ID et le Client Secret dans le fichier de configuration
|
||||||
35
docs/channels/dingtalk/README.ja.md
Normal file
35
docs/channels/dingtalk/README.ja.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
> [README](../../../README.ja.md) に戻る
|
||||||
|
|
||||||
|
# DingTalk
|
||||||
|
|
||||||
|
DingTalkはアリババの企業向けコミュニケーションプラットフォームで、中国のビジネス環境で広く利用されています。ストリーミング SDK を使用して持続的な接続を維持します。
|
||||||
|
|
||||||
|
## 設定
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"dingtalk": {
|
||||||
|
"enabled": true,
|
||||||
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| フィールド | 型 | 必須 | 説明 |
|
||||||
|
| ------------- | ------ | ---- | -------------------------------------------- |
|
||||||
|
| enabled | bool | はい | DingTalk チャンネルを有効にするかどうか |
|
||||||
|
| client_id | string | はい | DingTalk アプリケーションの Client ID |
|
||||||
|
| client_secret | string | はい | DingTalk アプリケーションの Client Secret |
|
||||||
|
| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 |
|
||||||
|
|
||||||
|
## セットアップ手順
|
||||||
|
|
||||||
|
1. [DingTalk オープンプラットフォーム](https://open.dingtalk.com/) にアクセスする
|
||||||
|
2. 企業内部アプリケーションを作成する
|
||||||
|
3. アプリケーション設定から Client ID と Client Secret を取得する
|
||||||
|
4. OAuth とイベントサブスクリプションを設定する(必要な場合)
|
||||||
|
5. Client ID と Client Secret を設定ファイルに入力する
|
||||||
35
docs/channels/dingtalk/README.md
Normal file
35
docs/channels/dingtalk/README.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
> Back to [README](../../../README.md)
|
||||||
|
|
||||||
|
# DingTalk
|
||||||
|
|
||||||
|
DingTalk is Alibaba's enterprise communication platform, widely used in Chinese workplaces. It uses a streaming SDK to maintain persistent connections.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"dingtalk": {
|
||||||
|
"enabled": true,
|
||||||
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| ------------- | ------ | -------- | -------------------------------------------------------- |
|
||||||
|
| enabled | bool | Yes | Whether to enable the DingTalk channel |
|
||||||
|
| client_id | string | Yes | Client ID of the DingTalk application |
|
||||||
|
| client_secret | string | Yes | Client Secret of the DingTalk application |
|
||||||
|
| allow_from | array | No | User ID whitelist; empty means all users are allowed |
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Go to the [DingTalk Open Platform](https://open.dingtalk.com/)
|
||||||
|
2. Create an internal enterprise application
|
||||||
|
3. Obtain the Client ID and Client Secret from the application settings
|
||||||
|
4. Configure OAuth and event subscriptions (if needed)
|
||||||
|
5. Fill in the Client ID and Client Secret in the configuration file
|
||||||
35
docs/channels/dingtalk/README.pt-br.md
Normal file
35
docs/channels/dingtalk/README.pt-br.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
> Voltar ao [README](../../../README.pt-br.md)
|
||||||
|
|
||||||
|
# DingTalk
|
||||||
|
|
||||||
|
DingTalk é a plataforma de comunicação empresarial da Alibaba, amplamente utilizada no ambiente corporativo chinês. Ela usa um SDK de streaming para manter conexões persistentes.
|
||||||
|
|
||||||
|
## Configuração
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"dingtalk": {
|
||||||
|
"enabled": true,
|
||||||
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Campo | Tipo | Obrigatório | Descrição |
|
||||||
|
| ------------- | ------ | ----------- | ---------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Sim | Se o canal DingTalk deve ser habilitado |
|
||||||
|
| client_id | string | Sim | Client ID do aplicativo DingTalk |
|
||||||
|
| client_secret | string | Sim | Client Secret do aplicativo DingTalk |
|
||||||
|
| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos |
|
||||||
|
|
||||||
|
## Configuração passo a passo
|
||||||
|
|
||||||
|
1. Acesse a [Plataforma Aberta DingTalk](https://open.dingtalk.com/)
|
||||||
|
2. Crie um aplicativo interno corporativo
|
||||||
|
3. Obtenha o Client ID e o Client Secret nas configurações do aplicativo
|
||||||
|
4. Configure OAuth e assinaturas de eventos (se necessário)
|
||||||
|
5. Preencha o Client ID e o Client Secret no arquivo de configuração
|
||||||
35
docs/channels/dingtalk/README.vi.md
Normal file
35
docs/channels/dingtalk/README.vi.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
> Quay lại [README](../../../README.vi.md)
|
||||||
|
|
||||||
|
# DingTalk
|
||||||
|
|
||||||
|
DingTalk là nền tảng giao tiếp doanh nghiệp của Alibaba, được sử dụng rộng rãi trong môi trường làm việc tại Trung Quốc. Nền tảng này sử dụng SDK streaming để duy trì kết nối liên tục.
|
||||||
|
|
||||||
|
## Cấu hình
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"dingtalk": {
|
||||||
|
"enabled": true,
|
||||||
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Trường | Kiểu | Bắt buộc | Mô tả |
|
||||||
|
| ------------- | ------ | -------- | ---------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Có | Có bật kênh DingTalk hay không |
|
||||||
|
| client_id | string | Có | Client ID của ứng dụng DingTalk |
|
||||||
|
| client_secret | string | Có | Client Secret của ứng dụng DingTalk |
|
||||||
|
| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả |
|
||||||
|
|
||||||
|
## Quy trình thiết lập
|
||||||
|
|
||||||
|
1. Truy cập [Nền tảng mở DingTalk](https://open.dingtalk.com/)
|
||||||
|
2. Tạo một ứng dụng nội bộ doanh nghiệp
|
||||||
|
3. Lấy Client ID và Client Secret từ cài đặt ứng dụng
|
||||||
|
4. Cấu hình OAuth và đăng ký sự kiện (nếu cần)
|
||||||
|
5. Điền Client ID và Client Secret vào file cấu hình
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
> 返回 [README](../../../README.zh.md)
|
||||||
|
|
||||||
# 钉钉
|
# 钉钉
|
||||||
|
|
||||||
钉钉是阿里巴巴的企业通讯平台,在中国职场中广受欢迎。它采用流式 SDK 来维持持久连接。
|
钉钉是阿里巴巴的企业通讯平台,在中国职场中广受欢迎。它采用流式 SDK 来维持持久连接。
|
||||||
|
|
|
||||||
39
docs/channels/discord/README.fr.md
Normal file
39
docs/channels/discord/README.fr.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
> Retour au [README](../../../README.fr.md)
|
||||||
|
|
||||||
|
# Discord
|
||||||
|
|
||||||
|
Discord est une application gratuite de chat vocal, vidéo et textuel conçue pour les communautés. PicoClaw se connecte aux serveurs Discord via l'API Bot Discord, avec prise en charge de la réception et de l'envoi de messages.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"discord": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_BOT_TOKEN",
|
||||||
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Champ | Type | Requis | Description |
|
||||||
|
| ------------- | ------ | ------ | --------------------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Oui | Activer ou non le canal Discord |
|
||||||
|
| token | string | Oui | Token du bot Discord |
|
||||||
|
| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs |
|
||||||
|
| group_trigger | object | Non | Paramètres de déclenchement de groupe (exemple : { "mention_only": false }) |
|
||||||
|
|
||||||
|
## Configuration initiale
|
||||||
|
|
||||||
|
1. Accéder au [Portail des développeurs Discord](https://discord.com/developers/applications) et créer une nouvelle application
|
||||||
|
2. Activer les Intents :
|
||||||
|
- Message Content Intent
|
||||||
|
- Server Members Intent
|
||||||
|
3. Obtenir le Token du bot
|
||||||
|
4. Renseigner le Token du bot dans le fichier de configuration
|
||||||
|
5. Inviter le bot sur le serveur et lui accorder les permissions nécessaires (ex. envoyer des messages, lire l'historique des messages)
|
||||||
39
docs/channels/discord/README.ja.md
Normal file
39
docs/channels/discord/README.ja.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
> [README](../../../README.ja.md) に戻る
|
||||||
|
|
||||||
|
# Discord
|
||||||
|
|
||||||
|
Discord はコミュニティ向けに設計された無料の音声・ビデオ・テキストチャットアプリケーションです。PicoClaw は Discord Bot API を通じて Discord サーバーに接続し、メッセージの受信と送信をサポートします。
|
||||||
|
|
||||||
|
## 設定
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"discord": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_BOT_TOKEN",
|
||||||
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| フィールド | 型 | 必須 | 説明 |
|
||||||
|
| ------------- | ------ | ------ | ----------------------------------------------------------------- |
|
||||||
|
| enabled | bool | はい | Discord チャンネルを有効にするかどうか |
|
||||||
|
| token | string | はい | Discord ボットトークン |
|
||||||
|
| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 |
|
||||||
|
| group_trigger | object | いいえ | グループトリガー設定(例: { "mention_only": false }) |
|
||||||
|
|
||||||
|
## セットアップ手順
|
||||||
|
|
||||||
|
1. [Discord 開発者ポータル](https://discord.com/developers/applications) にアクセスして新しいアプリケーションを作成する
|
||||||
|
2. Intents を有効にする:
|
||||||
|
- Message Content Intent
|
||||||
|
- Server Members Intent
|
||||||
|
3. Bot トークンを取得する
|
||||||
|
4. 設定ファイルに Bot トークンを入力する
|
||||||
|
5. ボットをサーバーに招待し、必要な権限を付与する(例: メッセージの送信、メッセージ履歴の読み取りなど)
|
||||||
39
docs/channels/discord/README.md
Normal file
39
docs/channels/discord/README.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
> Back to [README](../../../README.md)
|
||||||
|
|
||||||
|
# Discord
|
||||||
|
|
||||||
|
Discord is a free voice, video, and text chat application designed for communities. PicoClaw connects to Discord servers via the Discord Bot API, supporting both receiving and sending messages.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"discord": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_BOT_TOKEN",
|
||||||
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| ------------- | ------ | -------- | --------------------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Yes | Whether to enable the Discord channel |
|
||||||
|
| token | string | Yes | Discord Bot Token |
|
||||||
|
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
|
||||||
|
| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) |
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) and create a new application
|
||||||
|
2. Enable Intents:
|
||||||
|
- Message Content Intent
|
||||||
|
- Server Members Intent
|
||||||
|
3. Obtain the Bot Token
|
||||||
|
4. Fill in the Bot Token in the configuration file
|
||||||
|
5. Invite the bot to your server and grant the necessary permissions (e.g. Send Messages, Read Message History)
|
||||||
39
docs/channels/discord/README.pt-br.md
Normal file
39
docs/channels/discord/README.pt-br.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
> Voltar ao [README](../../../README.pt-br.md)
|
||||||
|
|
||||||
|
# Discord
|
||||||
|
|
||||||
|
Discord é um aplicativo gratuito de chat de voz, vídeo e texto projetado para comunidades. O PicoClaw se conecta a servidores Discord via Discord Bot API, com suporte para receber e enviar mensagens.
|
||||||
|
|
||||||
|
## Configuração
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"discord": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_BOT_TOKEN",
|
||||||
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Campo | Tipo | Obrigatório | Descrição |
|
||||||
|
| ------------- | ------ | ----------- | --------------------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Sim | Se o canal Discord deve ser habilitado |
|
||||||
|
| token | string | Sim | Token do Bot Discord |
|
||||||
|
| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários |
|
||||||
|
| group_trigger | object | Não | Configurações de gatilho de grupo (exemplo: { "mention_only": false }) |
|
||||||
|
|
||||||
|
## Configuração inicial
|
||||||
|
|
||||||
|
1. Acesse o [Portal de Desenvolvedores do Discord](https://discord.com/developers/applications) e crie uma nova aplicação
|
||||||
|
2. Habilite os Intents:
|
||||||
|
- Message Content Intent
|
||||||
|
- Server Members Intent
|
||||||
|
3. Obtenha o Token do Bot
|
||||||
|
4. Preencha o Token do Bot no arquivo de configuração
|
||||||
|
5. Convide o bot para o servidor e conceda as permissões necessárias (ex. enviar mensagens, ler histórico de mensagens)
|
||||||
39
docs/channels/discord/README.vi.md
Normal file
39
docs/channels/discord/README.vi.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
> Quay lại [README](../../../README.vi.md)
|
||||||
|
|
||||||
|
# Discord
|
||||||
|
|
||||||
|
Discord là ứng dụng chat thoại, video và văn bản miễn phí được thiết kế cho cộng đồng. PicoClaw kết nối với máy chủ Discord qua Discord Bot API, hỗ trợ nhận và gửi tin nhắn.
|
||||||
|
|
||||||
|
## Cấu hình
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"discord": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_BOT_TOKEN",
|
||||||
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Trường | Kiểu | Bắt buộc | Mô tả |
|
||||||
|
| ------------- | ------ | -------- | --------------------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Có | Có bật kênh Discord hay không |
|
||||||
|
| token | string | Có | Token Bot Discord |
|
||||||
|
| allow_from | array | Không | Danh sách trắng ID người dùng; để trống nghĩa là cho phép tất cả |
|
||||||
|
| group_trigger | object | Không | Cài đặt kích hoạt nhóm (ví dụ: { "mention_only": false }) |
|
||||||
|
|
||||||
|
## Hướng dẫn thiết lập
|
||||||
|
|
||||||
|
1. Truy cập [Discord Developer Portal](https://discord.com/developers/applications) và tạo ứng dụng mới
|
||||||
|
2. Bật các Intents:
|
||||||
|
- Message Content Intent
|
||||||
|
- Server Members Intent
|
||||||
|
3. Lấy Bot Token
|
||||||
|
4. Điền Bot Token vào file cấu hình
|
||||||
|
5. Mời bot vào máy chủ và cấp các quyền cần thiết (ví dụ: gửi tin nhắn, đọc lịch sử tin nhắn)
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
> 返回 [README](../../../README.zh.md)
|
||||||
|
|
||||||
# Discord
|
# Discord
|
||||||
|
|
||||||
Discord 是一个专为社区设计的免费语音、视频和文本聊天应用。PicoClaw 通过 Discord Bot API 连接到 Discord 服务器,支持接收和发送消息。
|
Discord 是一个专为社区设计的免费语音、视频和文本聊天应用。PicoClaw 通过 Discord Bot API 连接到 Discord 服务器,支持接收和发送消息。
|
||||||
|
|
|
||||||
52
docs/channels/feishu/README.fr.md
Normal file
52
docs/channels/feishu/README.fr.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
> Retour au [README](../../../README.fr.md)
|
||||||
|
|
||||||
|
# Feishu
|
||||||
|
|
||||||
|
Feishu (nom international : Lark) est une plateforme de collaboration d'entreprise de ByteDance. Elle prend en charge les marchés chinois et mondiaux via des connexions WebSocket pilotées par événements.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"feishu": {
|
||||||
|
"enabled": true,
|
||||||
|
"app_id": "cli_xxx",
|
||||||
|
"app_secret": "xxx",
|
||||||
|
"encrypt_key": "",
|
||||||
|
"verification_token": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Champ | Type | Requis | Description |
|
||||||
|
| --------------------- | ------ | ------ | --------------------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Oui | Activer ou non le canal Feishu |
|
||||||
|
| app_id | string | Oui | App ID de l'application Feishu (commence par `cli_`) |
|
||||||
|
| app_secret | string | Oui | App Secret de l'application Feishu |
|
||||||
|
| encrypt_key | string | Non | Clé de chiffrement pour les callbacks d'événements |
|
||||||
|
| verification_token | string | Non | Token utilisé pour la vérification des événements Webhook |
|
||||||
|
| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs |
|
||||||
|
| random_reaction_emoji | array | Non | Liste d'emojis de réaction aléatoires ; vide utilise le "Pin" par défaut |
|
||||||
|
|
||||||
|
## Configuration initiale
|
||||||
|
|
||||||
|
1. Accéder à la [plateforme ouverte Feishu](https://open.feishu.cn/) et créer une application
|
||||||
|
2. Activer la capacité **Bot** dans les paramètres de l'application
|
||||||
|
3. Créer une version et publier l'application (la configuration prend effet après la publication)
|
||||||
|
4. Obtenir l'**App ID** (commence par `cli_`) et l'**App Secret**
|
||||||
|
5. Renseigner l'App ID et l'App Secret dans le fichier de configuration PicoClaw
|
||||||
|
6. Exécuter `picoclaw gateway` pour démarrer le service
|
||||||
|
7. Rechercher le nom du bot dans Feishu et commencer une conversation
|
||||||
|
|
||||||
|
> PicoClaw se connecte à Feishu en mode WebSocket/SDK — aucune adresse de callback publique ni URL Webhook n'est requise.
|
||||||
|
>
|
||||||
|
> `encrypt_key` et `verification_token` sont optionnels ; l'activation du chiffrement des événements est recommandée pour les environnements de production.
|
||||||
|
>
|
||||||
|
> Pour les références d'emojis personnalisés, voir : [Liste des emojis Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
|
||||||
|
|
||||||
|
## Limitations de plateforme
|
||||||
|
|
||||||
|
> ⚠️ **Le canal Feishu ne prend pas en charge les appareils 32 bits.** Le SDK Feishu ne fournit que des builds 64 bits. Les architectures 32 bits (armv6, armv7, mipsle, etc.) ne peuvent pas utiliser le canal Feishu. Pour la messagerie sur des appareils 32 bits, utilisez Telegram, Discord ou OneBot.
|
||||||
52
docs/channels/feishu/README.ja.md
Normal file
52
docs/channels/feishu/README.ja.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
> [README](../../../README.ja.md) に戻る
|
||||||
|
|
||||||
|
# 飛書(Feishu)
|
||||||
|
|
||||||
|
飛書(国際名:Lark)は ByteDance が提供するエンタープライズコラボレーションプラットフォームです。イベント駆動型の WebSocket 接続を通じて、中国および世界市場の両方をサポートします。
|
||||||
|
|
||||||
|
## 設定
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"feishu": {
|
||||||
|
"enabled": true,
|
||||||
|
"app_id": "cli_xxx",
|
||||||
|
"app_secret": "xxx",
|
||||||
|
"encrypt_key": "",
|
||||||
|
"verification_token": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| フィールド | 型 | 必須 | 説明 |
|
||||||
|
| --------------------- | ------ | ------ | ----------------------------------------------------------------- |
|
||||||
|
| enabled | bool | はい | 飛書チャンネルを有効にするかどうか |
|
||||||
|
| app_id | string | はい | 飛書アプリケーションの App ID(`cli_` で始まる) |
|
||||||
|
| app_secret | string | はい | 飛書アプリケーションの App Secret |
|
||||||
|
| encrypt_key | string | いいえ | イベントコールバックの暗号化キー |
|
||||||
|
| verification_token | string | いいえ | Webhook イベント検証に使用するトークン |
|
||||||
|
| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 |
|
||||||
|
| random_reaction_emoji | array | いいえ | ランダムに追加する絵文字のリスト。空の場合はデフォルトの "Pin" を使用 |
|
||||||
|
|
||||||
|
## セットアップ手順
|
||||||
|
|
||||||
|
1. [飛書オープンプラットフォーム](https://open.feishu.cn/) にアクセスしてアプリケーションを作成する
|
||||||
|
2. アプリケーション設定で**ボット**機能を有効にする
|
||||||
|
3. バージョンを作成してアプリケーションを公開する(公開後に設定が有効になる)
|
||||||
|
4. **App ID**(`cli_` で始まる)と **App Secret** を取得する
|
||||||
|
5. PicoClaw 設定ファイルに App ID と App Secret を入力する
|
||||||
|
6. `picoclaw gateway` を実行してサービスを起動する
|
||||||
|
7. 飛書でボット名を検索して会話を始める
|
||||||
|
|
||||||
|
> PicoClaw は WebSocket/SDK モードで飛書に接続するため、公開コールバックアドレスや Webhook URL の設定は不要です。
|
||||||
|
>
|
||||||
|
> `encrypt_key` と `verification_token` はオプションですが、本番環境ではイベント暗号化を有効にすることを推奨します。
|
||||||
|
>
|
||||||
|
> カスタム絵文字の参考:[飛書絵文字リスト](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
|
||||||
|
|
||||||
|
## プラットフォーム制限
|
||||||
|
|
||||||
|
> ⚠️ **飛書チャネルは 32 ビットデバイスをサポートしていません。** 飛書 SDK は 64 ビットビルドのみ提供しています。armv6 / armv7 / mipsle などの 32 ビットアーキテクチャでは飛書チャネルを使用できません。32 ビットデバイスでのメッセージングには、Telegram、Discord、または OneBot をご利用ください。
|
||||||
52
docs/channels/feishu/README.md
Normal file
52
docs/channels/feishu/README.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
> Back to [README](../../../README.md)
|
||||||
|
|
||||||
|
# Feishu
|
||||||
|
|
||||||
|
Feishu (international name: Lark) is an enterprise collaboration platform by ByteDance. It supports both Chinese and global markets through event-driven WebSocket connections.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"feishu": {
|
||||||
|
"enabled": true,
|
||||||
|
"app_id": "cli_xxx",
|
||||||
|
"app_secret": "xxx",
|
||||||
|
"encrypt_key": "",
|
||||||
|
"verification_token": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| --------------------- | ------ | -------- | ------------------------------------------------------------------ |
|
||||||
|
| enabled | bool | Yes | Whether to enable the Feishu channel |
|
||||||
|
| app_id | string | Yes | App ID of the Feishu application (starts with `cli_`) |
|
||||||
|
| app_secret | string | Yes | App Secret of the Feishu application |
|
||||||
|
| encrypt_key | string | No | Encryption key for event callbacks |
|
||||||
|
| verification_token | string | No | Token used for Webhook event verification |
|
||||||
|
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
|
||||||
|
| random_reaction_emoji | array | No | List of random reaction emojis; empty uses the default "Pin" |
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Go to the [Feishu Open Platform](https://open.feishu.cn/) and create an application
|
||||||
|
2. Enable the **Bot** capability in the application settings
|
||||||
|
3. Create a version and publish the application (configuration takes effect only after publishing)
|
||||||
|
4. Obtain the **App ID** (starts with `cli_`) and **App Secret**
|
||||||
|
5. Fill in the App ID and App Secret in the PicoClaw configuration file
|
||||||
|
6. Run `picoclaw gateway` to start the service
|
||||||
|
7. Search for the bot name in Feishu and start a conversation
|
||||||
|
|
||||||
|
> PicoClaw connects to Feishu using WebSocket/SDK mode — no public callback address or Webhook URL is required.
|
||||||
|
>
|
||||||
|
> `encrypt_key` and `verification_token` are optional; enabling event encryption is recommended for production environments.
|
||||||
|
>
|
||||||
|
> For custom emoji references, see: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
|
||||||
|
|
||||||
|
## Platform Limitations
|
||||||
|
|
||||||
|
> ⚠️ **Feishu channel does not support 32-bit devices.** The Feishu SDK only provides 64-bit builds. Devices running armv6, armv7, mipsle, or other 32-bit architectures cannot use the Feishu channel. For messaging on 32-bit devices, use Telegram, Discord, or OneBot instead.
|
||||||
52
docs/channels/feishu/README.pt-br.md
Normal file
52
docs/channels/feishu/README.pt-br.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
> Voltar ao [README](../../../README.pt-br.md)
|
||||||
|
|
||||||
|
# Feishu
|
||||||
|
|
||||||
|
Feishu (nome internacional: Lark) é uma plataforma de colaboração empresarial da ByteDance. Suporta os mercados chinês e global por meio de conexões WebSocket orientadas a eventos.
|
||||||
|
|
||||||
|
## Configuração
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"feishu": {
|
||||||
|
"enabled": true,
|
||||||
|
"app_id": "cli_xxx",
|
||||||
|
"app_secret": "xxx",
|
||||||
|
"encrypt_key": "",
|
||||||
|
"verification_token": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Campo | Tipo | Obrigatório | Descrição |
|
||||||
|
| --------------------- | ------ | ----------- | -------------------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Sim | Se o canal Feishu deve ser habilitado |
|
||||||
|
| app_id | string | Sim | App ID da aplicação Feishu (começa com `cli_`) |
|
||||||
|
| app_secret | string | Sim | App Secret da aplicação Feishu |
|
||||||
|
| encrypt_key | string | Não | Chave de criptografia para callbacks de eventos |
|
||||||
|
| verification_token | string | Não | Token usado para verificação de eventos Webhook |
|
||||||
|
| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários |
|
||||||
|
| random_reaction_emoji | array | Não | Lista de emojis de reação aleatórios; vazio usa o "Pin" padrão |
|
||||||
|
|
||||||
|
## Configuração inicial
|
||||||
|
|
||||||
|
1. Acesse a [Plataforma Aberta Feishu](https://open.feishu.cn/) e crie uma aplicação
|
||||||
|
2. Habilite a capacidade de **Bot** nas configurações da aplicação
|
||||||
|
3. Crie uma versão e publique a aplicação (a configuração entra em vigor após a publicação)
|
||||||
|
4. Obtenha o **App ID** (começa com `cli_`) e o **App Secret**
|
||||||
|
5. Preencha o App ID e o App Secret no arquivo de configuração do PicoClaw
|
||||||
|
6. Execute `picoclaw gateway` para iniciar o serviço
|
||||||
|
7. Pesquise o nome do bot no Feishu e inicie uma conversa
|
||||||
|
|
||||||
|
> O PicoClaw se conecta ao Feishu usando o modo WebSocket/SDK — nenhum endereço de callback público ou URL de Webhook é necessário.
|
||||||
|
>
|
||||||
|
> `encrypt_key` e `verification_token` são opcionais; recomenda-se habilitar a criptografia de eventos em ambientes de produção.
|
||||||
|
>
|
||||||
|
> Para referências de emojis personalizados, consulte: [Lista de Emojis do Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
|
||||||
|
|
||||||
|
## Limitações de Plataforma
|
||||||
|
|
||||||
|
> ⚠️ **O canal Feishu não suporta dispositivos 32 bits.** O SDK do Feishu fornece apenas builds 64 bits. Arquiteturas 32 bits (armv6, armv7, mipsle, etc.) não podem usar o canal Feishu. Para mensagens em dispositivos 32 bits, use Telegram, Discord ou OneBot.
|
||||||
52
docs/channels/feishu/README.vi.md
Normal file
52
docs/channels/feishu/README.vi.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
> Quay lại [README](../../../README.vi.md)
|
||||||
|
|
||||||
|
# Feishu
|
||||||
|
|
||||||
|
Feishu (tên quốc tế: Lark) là nền tảng cộng tác doanh nghiệp của ByteDance. Hỗ trợ cả thị trường Trung Quốc và toàn cầu thông qua kết nối WebSocket theo hướng sự kiện.
|
||||||
|
|
||||||
|
## Cấu hình
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"feishu": {
|
||||||
|
"enabled": true,
|
||||||
|
"app_id": "cli_xxx",
|
||||||
|
"app_secret": "xxx",
|
||||||
|
"encrypt_key": "",
|
||||||
|
"verification_token": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Trường | Kiểu | Bắt buộc | Mô tả |
|
||||||
|
| --------------------- | ------ | -------- | ------------------------------------------------------------------------ |
|
||||||
|
| enabled | bool | Có | Có bật kênh Feishu hay không |
|
||||||
|
| app_id | string | Có | App ID của ứng dụng Feishu (bắt đầu bằng `cli_`) |
|
||||||
|
| app_secret | string | Có | App Secret của ứng dụng Feishu |
|
||||||
|
| encrypt_key | string | Không | Khóa mã hóa cho callback sự kiện |
|
||||||
|
| verification_token | string | Không | Token dùng để xác minh sự kiện Webhook |
|
||||||
|
| allow_from | array | Không | Danh sách trắng ID người dùng; để trống nghĩa là cho phép tất cả |
|
||||||
|
| random_reaction_emoji | array | Không | Danh sách emoji phản ứng ngẫu nhiên; để trống dùng "Pin" mặc định |
|
||||||
|
|
||||||
|
## Hướng dẫn thiết lập
|
||||||
|
|
||||||
|
1. Truy cập [Nền tảng Mở Feishu](https://open.feishu.cn/) và tạo ứng dụng
|
||||||
|
2. Bật khả năng **Bot** trong cài đặt ứng dụng
|
||||||
|
3. Tạo phiên bản và xuất bản ứng dụng (cấu hình có hiệu lực sau khi xuất bản)
|
||||||
|
4. Lấy **App ID** (bắt đầu bằng `cli_`) và **App Secret**
|
||||||
|
5. Điền App ID và App Secret vào file cấu hình PicoClaw
|
||||||
|
6. Chạy `picoclaw gateway` để khởi động dịch vụ
|
||||||
|
7. Tìm kiếm tên bot trong Feishu và bắt đầu trò chuyện
|
||||||
|
|
||||||
|
> PicoClaw kết nối với Feishu bằng chế độ WebSocket/SDK — không cần cấu hình địa chỉ callback công khai hay Webhook URL.
|
||||||
|
>
|
||||||
|
> `encrypt_key` và `verification_token` là tùy chọn; nên bật mã hóa sự kiện trong môi trường sản xuất.
|
||||||
|
>
|
||||||
|
> Tham khảo emoji tùy chỉnh: [Danh sách Emoji Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
|
||||||
|
|
||||||
|
## Giới hạn nền tảng
|
||||||
|
|
||||||
|
> ⚠️ **Kênh Feishu không hỗ trợ thiết bị 32 bit.** SDK Feishu chỉ cung cấp bản build 64 bit. Các kiến trúc 32 bit (armv6, armv7, mipsle, v.v.) không thể sử dụng kênh Feishu. Để nhắn tin trên thiết bị 32 bit, hãy dùng Telegram, Discord hoặc OneBot.
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
> 返回 [README](../../../README.zh.md)
|
||||||
|
|
||||||
# 飞书
|
# 飞书
|
||||||
|
|
||||||
飞书(国际版名称:Lark)是字节跳动旗下的企业协作平台。它通过事件驱动的 Webhook 同时支持中国和全球市场。
|
飞书(国际版名称:Lark)是字节跳动旗下的企业协作平台。它通过事件驱动的 Webhook 同时支持中国和全球市场。
|
||||||
|
|
@ -13,14 +15,15 @@
|
||||||
"app_secret": "xxx",
|
"app_secret": "xxx",
|
||||||
"encrypt_key": "",
|
"encrypt_key": "",
|
||||||
"verification_token": "",
|
"verification_token": "",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"is_lark": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 描述 |
|
| 字段 | 类型 | 必填 | 描述 |
|
||||||
| ------------------ | ------ | ---- | -------------------------------- |
|
| --------------------- | ------ | ---- | ------------------------------------------------------------------------------------------------ |
|
||||||
| enabled | bool | 是 | 是否启用飞书频道 |
|
| enabled | bool | 是 | 是否启用飞书频道 |
|
||||||
| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) |
|
| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) |
|
||||||
| app_secret | string | 是 | 飞书应用的 App Secret |
|
| app_secret | string | 是 | 飞书应用的 App Secret |
|
||||||
|
|
@ -28,12 +31,17 @@
|
||||||
| verification_token | string | 否 | 用于Webhook事件验证的Token |
|
| verification_token | string | 否 | 用于Webhook事件验证的Token |
|
||||||
| allow_from | array | 否 | 用户ID白名单,空表示所有用户 |
|
| allow_from | array | 否 | 用户ID白名单,空表示所有用户 |
|
||||||
| random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" |
|
| random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" |
|
||||||
|
| is_lark | bool | 否 | 是否使用 Lark 国际版域名(`open.larksuite.com`),默认为 `false`(使用飞书域名 `open.feishu.cn`) |
|
||||||
|
|
||||||
## 设置流程
|
## 设置流程
|
||||||
|
|
||||||
1. 前往 [飞书开放平台](https://open.feishu.cn/)创建应用程序
|
1. 前往 [飞书开放平台](https://open.feishu.cn/)(国际版用户请前往 [Lark 开放平台](https://open.larksuite.com/))创建应用程序
|
||||||
2. 获取 App ID 和 App Secret
|
2. 获取 App ID 和 App Secret
|
||||||
3. 配置事件订阅和Webhook URL
|
3. 配置事件订阅和Webhook URL
|
||||||
4. 设置加密(可选,生产环境建议启用)
|
4. 设置加密(可选,生产环境建议启用)
|
||||||
5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
|
5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
|
||||||
6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce))
|
6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce))
|
||||||
|
|
||||||
|
## 平台限制
|
||||||
|
|
||||||
|
> ⚠️ **飞书通道不支持 32 位设备。** 飞书官方 SDK 仅提供 64 位构建,armv6 / armv7 / mipsle 等 32 位架构无法使用飞书通道。如需在 32 位设备上接入即时通讯,请改用 Telegram、Discord 或 OneBot 等通道。
|
||||||
|
|
|
||||||
40
docs/channels/line/README.fr.md
Normal file
40
docs/channels/line/README.fr.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
> Retour au [README](../../../README.fr.md)
|
||||||
|
|
||||||
|
# Line
|
||||||
|
|
||||||
|
PicoClaw prend en charge LINE via l'API LINE Messaging avec des callbacks webhook.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"line": {
|
||||||
|
"enabled": true,
|
||||||
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
|
"webhook_path": "/webhook/line",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Champ | Type | Requis | Description |
|
||||||
|
| -------------------- | ------ | ------ | ------------------------------------------------------------------------ |
|
||||||
|
| enabled | bool | Oui | Activer ou non le canal LINE |
|
||||||
|
| channel_secret | string | Oui | Channel Secret de l'API LINE Messaging |
|
||||||
|
| channel_access_token | string | Oui | Channel Access Token de l'API LINE Messaging |
|
||||||
|
| webhook_path | string | Non | Chemin du webhook (par défaut : /webhook/line) |
|
||||||
|
| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs |
|
||||||
|
|
||||||
|
## Procédure de configuration
|
||||||
|
|
||||||
|
1. Rendez-vous sur la [LINE Developers Console](https://developers.line.biz/console/) et créez un fournisseur de services ainsi qu'un canal Messaging API
|
||||||
|
2. Obtenez le Channel Secret et le Channel Access Token
|
||||||
|
3. Configurez le webhook :
|
||||||
|
- LINE exige que les webhooks utilisent HTTPS. Vous devez donc déployer un serveur compatible HTTPS ou utiliser un outil de proxy inverse comme ngrok pour exposer votre serveur local sur Internet
|
||||||
|
- PicoClaw utilise un serveur HTTP Gateway partagé pour recevoir les callbacks webhook de tous les canaux, écoutant par défaut sur 127.0.0.1:18790
|
||||||
|
- Définissez l'URL du webhook sur `https://your-domain.com/webhook/line`, puis configurez un proxy inverse de votre domaine externe vers le Gateway local (port par défaut 18790)
|
||||||
|
- Activez le webhook et vérifiez l'URL
|
||||||
|
4. Renseignez le Channel Secret et le Channel Access Token dans le fichier de configuration
|
||||||
40
docs/channels/line/README.ja.md
Normal file
40
docs/channels/line/README.ja.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
> [README](../../../README.ja.md) に戻る
|
||||||
|
|
||||||
|
# Line
|
||||||
|
|
||||||
|
PicoClaw は LINE Messaging API と Webhook コールバックを通じて LINE をサポートします。
|
||||||
|
|
||||||
|
## 設定
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"line": {
|
||||||
|
"enabled": true,
|
||||||
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
|
"webhook_path": "/webhook/line",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| フィールド | 型 | 必須 | 説明 |
|
||||||
|
| -------------------- | ------ | ------ | ------------------------------------------------------------------ |
|
||||||
|
| enabled | bool | はい | LINE チャンネルを有効にするかどうか |
|
||||||
|
| channel_secret | string | はい | LINE Messaging API の Channel Secret |
|
||||||
|
| channel_access_token | string | はい | LINE Messaging API の Channel Access Token |
|
||||||
|
| webhook_path | string | いいえ | Webhook のパス(デフォルト: /webhook/line) |
|
||||||
|
| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 |
|
||||||
|
|
||||||
|
## セットアップ手順
|
||||||
|
|
||||||
|
1. [LINE Developers Console](https://developers.line.biz/console/) にアクセスし、サービスプロバイダーと Messaging API チャンネルを作成する
|
||||||
|
2. Channel Secret と Channel Access Token を取得する
|
||||||
|
3. Webhook を設定する:
|
||||||
|
- LINE は Webhook に HTTPS が必要なため、HTTPS 対応サーバーをデプロイするか、ngrok などのリバースプロキシツールを使用してローカルサーバーをインターネットに公開する必要があります
|
||||||
|
- PicoClaw は共有の Gateway HTTP サーバーを使用してすべてのチャンネルの Webhook コールバックを受信します。デフォルトのリッスンアドレスは 127.0.0.1:18790 です
|
||||||
|
- Webhook URL を `https://your-domain.com/webhook/line` に設定し、外部ドメインをローカルの Gateway(デフォルトポート 18790)にリバースプロキシする
|
||||||
|
- Webhook を有効にして URL を検証する
|
||||||
|
4. Channel Secret と Channel Access Token を設定ファイルに入力する
|
||||||
40
docs/channels/line/README.md
Normal file
40
docs/channels/line/README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
> Back to [README](../../../README.md)
|
||||||
|
|
||||||
|
# Line
|
||||||
|
|
||||||
|
PicoClaw supports LINE through the LINE Messaging API with webhook callbacks.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"line": {
|
||||||
|
"enabled": true,
|
||||||
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
|
"webhook_path": "/webhook/line",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| -------------------- | ------ | -------- | ------------------------------------------------------------------ |
|
||||||
|
| enabled | bool | Yes | Whether to enable the LINE channel |
|
||||||
|
| channel_secret | string | Yes | Channel Secret for the LINE Messaging API |
|
||||||
|
| channel_access_token | string | Yes | Channel Access Token for the LINE Messaging API |
|
||||||
|
| webhook_path | string | No | Webhook path (default: /webhook/line) |
|
||||||
|
| allow_from | array | No | User ID whitelist; empty means all users are allowed |
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Go to the [LINE Developers Console](https://developers.line.biz/console/) and create a provider and a Messaging API channel
|
||||||
|
2. Obtain the Channel Secret and Channel Access Token
|
||||||
|
3. Configure the webhook:
|
||||||
|
- LINE requires webhooks to use HTTPS, so you need to deploy a server with HTTPS support, or use a reverse proxy tool like ngrok to expose your local server to the internet
|
||||||
|
- PicoClaw uses a shared Gateway HTTP server to receive webhook callbacks for all channels, listening on 127.0.0.1:18790 by default
|
||||||
|
- Set the Webhook URL to `https://your-domain.com/webhook/line`, then reverse-proxy your external domain to the local Gateway (default port 18790)
|
||||||
|
- Enable the webhook and verify the URL
|
||||||
|
4. Fill in the Channel Secret and Channel Access Token in the configuration file
|
||||||
40
docs/channels/line/README.pt-br.md
Normal file
40
docs/channels/line/README.pt-br.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
> Voltar ao [README](../../../README.pt-br.md)
|
||||||
|
|
||||||
|
# Line
|
||||||
|
|
||||||
|
O PicoClaw suporta o LINE por meio da LINE Messaging API com callbacks de webhook.
|
||||||
|
|
||||||
|
## Configuração
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"line": {
|
||||||
|
"enabled": true,
|
||||||
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
|
"webhook_path": "/webhook/line",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Campo | Tipo | Obrigatório | Descrição |
|
||||||
|
| -------------------- | ------ | ----------- | ---------------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Sim | Se o canal LINE deve ser habilitado |
|
||||||
|
| channel_secret | string | Sim | Channel Secret da LINE Messaging API |
|
||||||
|
| channel_access_token | string | Sim | Channel Access Token da LINE Messaging API |
|
||||||
|
| webhook_path | string | Não | Caminho do webhook (padrão: /webhook/line) |
|
||||||
|
| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos |
|
||||||
|
|
||||||
|
## Configuração passo a passo
|
||||||
|
|
||||||
|
1. Acesse o [LINE Developers Console](https://developers.line.biz/console/) e crie um provedor de serviços e um canal Messaging API
|
||||||
|
2. Obtenha o Channel Secret e o Channel Access Token
|
||||||
|
3. Configure o webhook:
|
||||||
|
- O LINE exige que os webhooks usem HTTPS, portanto é necessário implantar um servidor com suporte a HTTPS ou usar uma ferramenta de proxy reverso como o ngrok para expor seu servidor local à internet
|
||||||
|
- O PicoClaw usa um servidor HTTP Gateway compartilhado para receber callbacks de webhook de todos os canais, escutando em 127.0.0.1:18790 por padrão
|
||||||
|
- Defina a URL do webhook como `https://your-domain.com/webhook/line` e configure um proxy reverso do seu domínio externo para o Gateway local (porta padrão 18790)
|
||||||
|
- Ative o webhook e verifique a URL
|
||||||
|
4. Preencha o Channel Secret e o Channel Access Token no arquivo de configuração
|
||||||
40
docs/channels/line/README.vi.md
Normal file
40
docs/channels/line/README.vi.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
> Quay lại [README](../../../README.vi.md)
|
||||||
|
|
||||||
|
# Line
|
||||||
|
|
||||||
|
PicoClaw hỗ trợ LINE thông qua LINE Messaging API kết hợp với webhook callback.
|
||||||
|
|
||||||
|
## Cấu hình
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"line": {
|
||||||
|
"enabled": true,
|
||||||
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
|
"webhook_path": "/webhook/line",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Trường | Kiểu | Bắt buộc | Mô tả |
|
||||||
|
| -------------------- | ------ | -------- | ---------------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Có | Có bật kênh LINE hay không |
|
||||||
|
| channel_secret | string | Có | Channel Secret của LINE Messaging API |
|
||||||
|
| channel_access_token | string | Có | Channel Access Token của LINE Messaging API |
|
||||||
|
| webhook_path | string | Không | Đường dẫn webhook (mặc định: /webhook/line) |
|
||||||
|
| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả |
|
||||||
|
|
||||||
|
## Quy trình thiết lập
|
||||||
|
|
||||||
|
1. Truy cập [LINE Developers Console](https://developers.line.biz/console/) và tạo một nhà cung cấp dịch vụ cùng một kênh Messaging API
|
||||||
|
2. Lấy Channel Secret và Channel Access Token
|
||||||
|
3. Cấu hình webhook:
|
||||||
|
- LINE yêu cầu webhook phải sử dụng HTTPS, vì vậy bạn cần triển khai máy chủ hỗ trợ HTTPS hoặc dùng công cụ reverse proxy như ngrok để expose máy chủ cục bộ ra internet
|
||||||
|
- PicoClaw sử dụng máy chủ HTTP Gateway dùng chung để nhận webhook callback cho tất cả các kênh, mặc định lắng nghe tại 127.0.0.1:18790
|
||||||
|
- Đặt Webhook URL thành `https://your-domain.com/webhook/line`, sau đó reverse proxy tên miền bên ngoài về Gateway cục bộ (cổng mặc định 18790)
|
||||||
|
- Bật webhook và xác minh URL
|
||||||
|
4. Điền Channel Secret và Channel Access Token vào file cấu hình
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
> 返回 [README](../../../README.zh.md)
|
||||||
|
|
||||||
# Line
|
# Line
|
||||||
|
|
||||||
PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的支持。
|
PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的支持。
|
||||||
|
|
|
||||||
35
docs/channels/maixcam/README.fr.md
Normal file
35
docs/channels/maixcam/README.fr.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
> Retour au [README](../../../README.fr.md)
|
||||||
|
|
||||||
|
# MaixCam
|
||||||
|
|
||||||
|
MaixCam est un canal dédié à la connexion aux caméras AI Sipeed MaixCAM et MaixCAM2. Il utilise des sockets TCP pour une communication bidirectionnelle et prend en charge les scénarios de déploiement d'IA en périphérie.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"maixcam": {
|
||||||
|
"enabled": true,
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 18790,
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Champ | Type | Requis | Description |
|
||||||
|
| ---------- | ------ | ------ | --------------------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Oui | Activer ou non le canal MaixCam |
|
||||||
|
| host | string | Oui | Adresse d'écoute du serveur TCP |
|
||||||
|
| port | int | Oui | Port d'écoute du serveur TCP |
|
||||||
|
| allow_from | array | Non | Liste blanche d'identifiants d'appareils ; vide signifie tous les appareils |
|
||||||
|
|
||||||
|
## Cas d'utilisation
|
||||||
|
|
||||||
|
Le canal MaixCam permet à PicoClaw de fonctionner comme backend IA pour les appareils en périphérie :
|
||||||
|
|
||||||
|
- **Surveillance intelligente** : MaixCAM envoie des images ; PicoClaw les analyse via des modèles de vision
|
||||||
|
- **Contrôle IoT** : Les appareils envoient des données de capteurs ; PicoClaw coordonne les réponses
|
||||||
|
- **IA hors ligne** : Déployer PicoClaw sur un réseau local pour une inférence à faible latence
|
||||||
35
docs/channels/maixcam/README.ja.md
Normal file
35
docs/channels/maixcam/README.ja.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
> [README](../../../README.ja.md) に戻る
|
||||||
|
|
||||||
|
# MaixCam
|
||||||
|
|
||||||
|
MaixCam は、Sipeed MaixCAM および MaixCAM2 AI カメラデバイスへの接続専用チャンネルです。TCP ソケットを使用した双方向通信を実装し、エッジ AI デプロイメントシナリオをサポートします。
|
||||||
|
|
||||||
|
## 設定
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"maixcam": {
|
||||||
|
"enabled": true,
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 18790,
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| フィールド | 型 | 必須 | 説明 |
|
||||||
|
| ---------- | ------ | ------ | ------------------------------------------------------------- |
|
||||||
|
| enabled | bool | はい | MaixCam チャンネルを有効にするかどうか |
|
||||||
|
| host | string | はい | TCP サーバーのリッスンアドレス |
|
||||||
|
| port | int | はい | TCP サーバーのリッスンポート |
|
||||||
|
| allow_from | array | いいえ | 許可するデバイスIDのリスト。空の場合はすべてのデバイスを許可 |
|
||||||
|
|
||||||
|
## ユースケース
|
||||||
|
|
||||||
|
MaixCam チャンネルにより、PicoClaw はエッジデバイスの AI バックエンドとして機能できます:
|
||||||
|
|
||||||
|
- **スマート監視**:MaixCAM が画像フレームを送信し、PicoClaw がビジョンモデルで分析する
|
||||||
|
- **IoT 制御**:デバイスがセンサーデータを送信し、PicoClaw がレスポンスを調整する
|
||||||
|
- **オフライン AI**:ローカルネットワークに PicoClaw をデプロイして低遅延推論を実現する
|
||||||
35
docs/channels/maixcam/README.md
Normal file
35
docs/channels/maixcam/README.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
> Back to [README](../../../README.md)
|
||||||
|
|
||||||
|
# MaixCam
|
||||||
|
|
||||||
|
MaixCam is a dedicated channel for connecting to Sipeed MaixCAM and MaixCAM2 AI camera devices. It uses TCP sockets for bidirectional communication and supports edge AI deployment scenarios.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"maixcam": {
|
||||||
|
"enabled": true,
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 18790,
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| ---------- | ------ | -------- | ---------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Yes | Whether to enable the MaixCam channel |
|
||||||
|
| host | string | Yes | TCP server listening address |
|
||||||
|
| port | int | Yes | TCP server listening port |
|
||||||
|
| allow_from | array | No | Allowlist of device IDs; empty means all devices are allowed |
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
The MaixCam channel enables PicoClaw to act as an AI backend for edge devices:
|
||||||
|
|
||||||
|
- **Smart Surveillance**: MaixCAM sends image frames; PicoClaw analyzes them using vision models
|
||||||
|
- **IoT Control**: Devices send sensor data; PicoClaw coordinates responses
|
||||||
|
- **Offline AI**: Deploy PicoClaw on a local network for low-latency inference
|
||||||
35
docs/channels/maixcam/README.pt-br.md
Normal file
35
docs/channels/maixcam/README.pt-br.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
> Voltar ao [README](../../../README.pt-br.md)
|
||||||
|
|
||||||
|
# MaixCam
|
||||||
|
|
||||||
|
MaixCam é um canal dedicado para conectar dispositivos de câmera AI Sipeed MaixCAM e MaixCAM2. Utiliza sockets TCP para comunicação bidirecional e suporta cenários de implantação de IA na borda.
|
||||||
|
|
||||||
|
## Configuração
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"maixcam": {
|
||||||
|
"enabled": true,
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 18790,
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Campo | Tipo | Obrigatório | Descrição |
|
||||||
|
| ---------- | ------ | ----------- | -------------------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Sim | Se o canal MaixCam deve ser habilitado |
|
||||||
|
| host | string | Sim | Endereço de escuta do servidor TCP |
|
||||||
|
| port | int | Sim | Porta de escuta do servidor TCP |
|
||||||
|
| allow_from | array | Não | Lista de IDs de dispositivos permitidos; vazio significa todos os dispositivos |
|
||||||
|
|
||||||
|
## Casos de uso
|
||||||
|
|
||||||
|
O canal MaixCam permite que o PicoClaw atue como backend de IA para dispositivos de borda:
|
||||||
|
|
||||||
|
- **Vigilância inteligente**: MaixCAM envia quadros de imagem; PicoClaw os analisa usando modelos de visão
|
||||||
|
- **Controle IoT**: Dispositivos enviam dados de sensores; PicoClaw coordena as respostas
|
||||||
|
- **IA offline**: Implante o PicoClaw em uma rede local para inferência de baixa latência
|
||||||
35
docs/channels/maixcam/README.vi.md
Normal file
35
docs/channels/maixcam/README.vi.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
> Quay lại [README](../../../README.vi.md)
|
||||||
|
|
||||||
|
# MaixCam
|
||||||
|
|
||||||
|
MaixCam là kênh chuyên dụng để kết nối với các thiết bị camera AI Sipeed MaixCAM và MaixCAM2. Sử dụng TCP socket để giao tiếp hai chiều và hỗ trợ các kịch bản triển khai AI tại biên.
|
||||||
|
|
||||||
|
## Cấu hình
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"maixcam": {
|
||||||
|
"enabled": true,
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 18790,
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Trường | Kiểu | Bắt buộc | Mô tả |
|
||||||
|
| ---------- | ------ | -------- | ------------------------------------------------------------------------ |
|
||||||
|
| enabled | bool | Có | Có bật kênh MaixCam hay không |
|
||||||
|
| host | string | Có | Địa chỉ lắng nghe của máy chủ TCP |
|
||||||
|
| port | int | Có | Cổng lắng nghe của máy chủ TCP |
|
||||||
|
| allow_from | array | Không | Danh sách trắng ID thiết bị; để trống nghĩa là cho phép tất cả thiết bị |
|
||||||
|
|
||||||
|
## Trường hợp sử dụng
|
||||||
|
|
||||||
|
Kênh MaixCam cho phép PicoClaw hoạt động như backend AI cho các thiết bị biên:
|
||||||
|
|
||||||
|
- **Giám sát thông minh**: MaixCAM gửi khung hình ảnh; PicoClaw phân tích bằng mô hình thị giác
|
||||||
|
- **Điều khiển IoT**: Thiết bị gửi dữ liệu cảm biến; PicoClaw điều phối phản hồi
|
||||||
|
- **AI ngoại tuyến**: Triển khai PicoClaw trên mạng nội bộ để suy luận độ trễ thấp
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
> 返回 [README](../../../README.zh.md)
|
||||||
|
|
||||||
# MaixCam
|
# MaixCam
|
||||||
|
|
||||||
MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的通道。它采用 TCP 套接字实现双向通信,支持边缘 AI 部署场景。
|
MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的通道。它采用 TCP 套接字实现双向通信,支持边缘 AI 部署场景。
|
||||||
|
|
@ -9,7 +11,8 @@ MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的
|
||||||
"channels": {
|
"channels": {
|
||||||
"maixcam": {
|
"maixcam": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"server_address": "0.0.0.0:8899",
|
"host": "0.0.0.0",
|
||||||
|
"port": 18790,
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -17,9 +20,10 @@ MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的
|
||||||
```
|
```
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 描述 |
|
| 字段 | 类型 | 必填 | 描述 |
|
||||||
| -------------- | ------ | ---- | -------------------------------- |
|
| ---------- | ------ | ---- | -------------------------------- |
|
||||||
| enabled | bool | 是 | 是否启用 MaixCam 频道 |
|
| enabled | bool | 是 | 是否启用 MaixCam 频道 |
|
||||||
| server_address | string | 是 | TCP 服务器监听地址和端口 |
|
| host | string | 是 | TCP 服务器监听地址 |
|
||||||
|
| port | int | 是 | TCP 服务器监听端口 |
|
||||||
| allow_from | array | 否 | 设备ID白名单,空表示允许所有设备 |
|
| allow_from | array | 否 | 设备ID白名单,空表示允许所有设备 |
|
||||||
|
|
||||||
## 使用场景
|
## 使用场景
|
||||||
|
|
|
||||||
64
docs/channels/matrix/README.fr.md
Normal file
64
docs/channels/matrix/README.fr.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
> Retour au [README](../../../README.fr.md)
|
||||||
|
|
||||||
|
# Guide de configuration du canal Matrix
|
||||||
|
|
||||||
|
## 1. Exemple de configuration
|
||||||
|
|
||||||
|
Ajoutez ceci à `config.json` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"matrix": {
|
||||||
|
"enabled": true,
|
||||||
|
"homeserver": "https://matrix.org",
|
||||||
|
"user_id": "@your-bot:matrix.org",
|
||||||
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
"device_id": "",
|
||||||
|
"join_on_invite": true,
|
||||||
|
"allow_from": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"enabled": true,
|
||||||
|
"text": "Thinking..."
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": "",
|
||||||
|
"message_format": "richtext"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Référence des champs
|
||||||
|
|
||||||
|
| Champ | Type | Requis | Description |
|
||||||
|
|----------------------|----------|--------|-------------|
|
||||||
|
| enabled | bool | Oui | Activer ou désactiver le canal Matrix |
|
||||||
|
| homeserver | string | Oui | URL du homeserver Matrix (par exemple `https://matrix.org`) |
|
||||||
|
| user_id | string | Oui | ID utilisateur Matrix du bot (par exemple `@bot:matrix.org`) |
|
||||||
|
| access_token | string | Oui | Jeton d'accès du bot |
|
||||||
|
| device_id | string | Non | ID d'appareil Matrix optionnel |
|
||||||
|
| join_on_invite | bool | Non | Rejoindre automatiquement les salons invités |
|
||||||
|
| allow_from | []string | Non | Liste blanche d'utilisateurs (IDs Matrix) |
|
||||||
|
| group_trigger | object | Non | Stratégie de déclenchement de groupe (`mention_only` / `prefixes`) |
|
||||||
|
| placeholder | object | Non | Configuration du message de remplacement |
|
||||||
|
| reasoning_channel_id | string | Non | Canal cible pour la sortie de raisonnement |
|
||||||
|
| message_format | string | Non | Format de sortie : `"richtext"` (défaut) rend le markdown en HTML ; `"plain"` envoie du texte brut uniquement |
|
||||||
|
|
||||||
|
## 3. Fonctionnalités actuellement supportées
|
||||||
|
|
||||||
|
- Envoi/réception de messages texte avec rendu markdown (gras, italique, titres, blocs de code, etc.)
|
||||||
|
- Format de message configurable (`richtext` / `plain`)
|
||||||
|
- Téléchargement d'images/audio/vidéo/fichiers entrants (MediaStore en priorité, chemin local en secours)
|
||||||
|
- Normalisation de l'audio entrant dans le flux de transcription existant (`[audio: ...]`)
|
||||||
|
- Upload et envoi d'images/audio/vidéo/fichiers sortants
|
||||||
|
- Règles de déclenchement de groupe (y compris le mode mention uniquement)
|
||||||
|
- État de frappe (`m.typing`)
|
||||||
|
- Message de remplacement + remplacement de la réponse finale
|
||||||
|
- Rejoindre automatiquement les salons invités (peut être désactivé)
|
||||||
|
|
||||||
|
## 4. TODO
|
||||||
|
|
||||||
|
- Améliorations des métadonnées des médias riches (par exemple taille et miniatures des images/vidéos)
|
||||||
64
docs/channels/matrix/README.ja.md
Normal file
64
docs/channels/matrix/README.ja.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
> [README](../../../README.ja.md) に戻る
|
||||||
|
|
||||||
|
# Matrix チャンネル設定ガイド
|
||||||
|
|
||||||
|
## 1. 設定例
|
||||||
|
|
||||||
|
`config.json` に以下を追加してください:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"matrix": {
|
||||||
|
"enabled": true,
|
||||||
|
"homeserver": "https://matrix.org",
|
||||||
|
"user_id": "@your-bot:matrix.org",
|
||||||
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
"device_id": "",
|
||||||
|
"join_on_invite": true,
|
||||||
|
"allow_from": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"enabled": true,
|
||||||
|
"text": "Thinking..."
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": "",
|
||||||
|
"message_format": "richtext"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. フィールドリファレンス
|
||||||
|
|
||||||
|
| フィールド | 型 | 必須 | 説明 |
|
||||||
|
|----------------------|----------|------|------|
|
||||||
|
| enabled | bool | はい | Matrix チャンネルの有効/無効 |
|
||||||
|
| homeserver | string | はい | Matrix ホームサーバー URL(例:`https://matrix.org`) |
|
||||||
|
| user_id | string | はい | ボットの Matrix ユーザー ID(例:`@bot:matrix.org`) |
|
||||||
|
| access_token | string | はい | ボットのアクセストークン |
|
||||||
|
| device_id | string | いいえ | オプションの Matrix デバイス ID |
|
||||||
|
| join_on_invite | bool | いいえ | 招待されたルームに自動参加 |
|
||||||
|
| allow_from | []string | いいえ | ユーザーホワイトリスト(Matrix ユーザー ID) |
|
||||||
|
| group_trigger | object | いいえ | グループトリガー戦略(`mention_only` / `prefixes`) |
|
||||||
|
| placeholder | object | いいえ | プレースホルダーメッセージ設定 |
|
||||||
|
| reasoning_channel_id | string | いいえ | 推論出力のターゲットチャンネル |
|
||||||
|
| message_format | string | いいえ | 出力形式:`"richtext"`(デフォルト)は markdown を HTML としてレンダリング;`"plain"` はプレーンテキストのみ送信 |
|
||||||
|
|
||||||
|
## 3. 現在サポートされている機能
|
||||||
|
|
||||||
|
- markdown レンダリング付きテキストメッセージ送受信(太字、斜体、見出し、コードブロックなど)
|
||||||
|
- 設定可能なメッセージ形式(`richtext` / `plain`)
|
||||||
|
- 受信画像/音声/動画/ファイルのダウンロード(MediaStore 優先、ローカルパスフォールバック)
|
||||||
|
- 受信音声の既存文字起こしフローへの正規化(`[audio: ...]`)
|
||||||
|
- 送信画像/音声/動画/ファイルのアップロードと送信
|
||||||
|
- グループトリガールール(メンションのみモードを含む)
|
||||||
|
- タイピング状態(`m.typing`)
|
||||||
|
- プレースホルダーメッセージ + 最終返信の置き換え
|
||||||
|
- 招待されたルームへの自動参加(無効化可能)
|
||||||
|
|
||||||
|
## 4. TODO
|
||||||
|
|
||||||
|
- リッチメディアメタデータの改善(例:画像/動画のサイズとサムネイル)
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
> Back to [README](../../../README.md)
|
||||||
|
|
||||||
# Matrix Channel Configuration Guide
|
# Matrix Channel Configuration Guide
|
||||||
|
|
||||||
## 1. Example Configuration
|
## 1. Example Configuration
|
||||||
|
|
|
||||||
64
docs/channels/matrix/README.pt-br.md
Normal file
64
docs/channels/matrix/README.pt-br.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
> Voltar ao [README](../../../README.pt-br.md)
|
||||||
|
|
||||||
|
# Guia de Configuração do Canal Matrix
|
||||||
|
|
||||||
|
## 1. Exemplo de Configuração
|
||||||
|
|
||||||
|
Adicione isto ao `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"matrix": {
|
||||||
|
"enabled": true,
|
||||||
|
"homeserver": "https://matrix.org",
|
||||||
|
"user_id": "@your-bot:matrix.org",
|
||||||
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
"device_id": "",
|
||||||
|
"join_on_invite": true,
|
||||||
|
"allow_from": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"enabled": true,
|
||||||
|
"text": "Thinking..."
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": "",
|
||||||
|
"message_format": "richtext"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Referência de Campos
|
||||||
|
|
||||||
|
| Campo | Tipo | Obrigatório | Descrição |
|
||||||
|
|----------------------|----------|-------------|-----------|
|
||||||
|
| enabled | bool | Sim | Habilitar ou desabilitar o canal Matrix |
|
||||||
|
| homeserver | string | Sim | URL do homeserver Matrix (por exemplo `https://matrix.org`) |
|
||||||
|
| user_id | string | Sim | ID de usuário Matrix do bot (por exemplo `@bot:matrix.org`) |
|
||||||
|
| access_token | string | Sim | Token de acesso do bot |
|
||||||
|
| device_id | string | Não | ID de dispositivo Matrix opcional |
|
||||||
|
| join_on_invite | bool | Não | Entrar automaticamente em salas convidadas |
|
||||||
|
| allow_from | []string | Não | Lista branca de usuários (IDs Matrix) |
|
||||||
|
| group_trigger | object | Não | Estratégia de gatilho de grupo (`mention_only` / `prefixes`) |
|
||||||
|
| placeholder | object | Não | Configuração de mensagem de espaço reservado |
|
||||||
|
| reasoning_channel_id | string | Não | Canal alvo para saída de raciocínio |
|
||||||
|
| message_format | string | Não | Formato de saída: `"richtext"` (padrão) renderiza markdown como HTML; `"plain"` envia apenas texto simples |
|
||||||
|
|
||||||
|
## 3. Suporte Atual
|
||||||
|
|
||||||
|
- Envio/recebimento de mensagens de texto com renderização markdown (negrito, itálico, cabeçalhos, blocos de código, etc.)
|
||||||
|
- Formato de mensagem configurável (`richtext` / `plain`)
|
||||||
|
- Download de imagens/áudio/vídeo/arquivos recebidos (MediaStore primeiro, fallback para caminho local)
|
||||||
|
- Normalização de áudio recebido no fluxo de transcrição existente (`[audio: ...]`)
|
||||||
|
- Upload e envio de imagens/áudio/vídeo/arquivos de saída
|
||||||
|
- Regras de gatilho de grupo (incluindo modo somente menção)
|
||||||
|
- Estado de digitação (`m.typing`)
|
||||||
|
- Mensagem de espaço reservado + substituição de resposta final
|
||||||
|
- Entrada automática em salas convidadas (pode ser desabilitado)
|
||||||
|
|
||||||
|
## 4. TODO
|
||||||
|
|
||||||
|
- Melhorias nos metadados de mídia rica (por exemplo tamanho e miniaturas de imagens/vídeos)
|
||||||
64
docs/channels/matrix/README.vi.md
Normal file
64
docs/channels/matrix/README.vi.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
> Quay lại [README](../../../README.vi.md)
|
||||||
|
|
||||||
|
# Hướng dẫn Cấu hình Kênh Matrix
|
||||||
|
|
||||||
|
## 1. Cấu hình Mẫu
|
||||||
|
|
||||||
|
Thêm vào `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"matrix": {
|
||||||
|
"enabled": true,
|
||||||
|
"homeserver": "https://matrix.org",
|
||||||
|
"user_id": "@your-bot:matrix.org",
|
||||||
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
"device_id": "",
|
||||||
|
"join_on_invite": true,
|
||||||
|
"allow_from": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"enabled": true,
|
||||||
|
"text": "Thinking..."
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": "",
|
||||||
|
"message_format": "richtext"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Tham chiếu Trường
|
||||||
|
|
||||||
|
| Trường | Kiểu | Bắt buộc | Mô tả |
|
||||||
|
|----------------------|----------|----------|-------|
|
||||||
|
| enabled | bool | Có | Bật hoặc tắt kênh Matrix |
|
||||||
|
| homeserver | string | Có | URL homeserver Matrix (ví dụ `https://matrix.org`) |
|
||||||
|
| user_id | string | Có | ID người dùng Matrix của bot (ví dụ `@bot:matrix.org`) |
|
||||||
|
| access_token | string | Có | Token truy cập của bot |
|
||||||
|
| device_id | string | Không | ID thiết bị Matrix tùy chọn |
|
||||||
|
| join_on_invite | bool | Không | Tự động tham gia phòng được mời |
|
||||||
|
| allow_from | []string | Không | Danh sách trắng người dùng (ID Matrix) |
|
||||||
|
| group_trigger | object | Không | Chiến lược kích hoạt nhóm (`mention_only` / `prefixes`) |
|
||||||
|
| placeholder | object | Không | Cấu hình tin nhắn giữ chỗ |
|
||||||
|
| reasoning_channel_id | string | Không | Kênh đích cho đầu ra suy luận |
|
||||||
|
| message_format | string | Không | Định dạng đầu ra: `"richtext"` (mặc định) render markdown thành HTML; `"plain"` chỉ gửi văn bản thuần |
|
||||||
|
|
||||||
|
## 3. Tính năng Hiện tại
|
||||||
|
|
||||||
|
- Gửi/nhận tin nhắn văn bản với render markdown (đậm, nghiêng, tiêu đề, khối code, v.v.)
|
||||||
|
- Định dạng tin nhắn có thể cấu hình (`richtext` / `plain`)
|
||||||
|
- Tải xuống hình ảnh/âm thanh/video/tệp đến (MediaStore trước, fallback đường dẫn cục bộ)
|
||||||
|
- Chuẩn hóa âm thanh đến vào luồng phiên âm hiện có (`[audio: ...]`)
|
||||||
|
- Tải lên và gửi hình ảnh/âm thanh/video/tệp đi
|
||||||
|
- Quy tắc kích hoạt nhóm (bao gồm chế độ chỉ đề cập)
|
||||||
|
- Trạng thái đang gõ (`m.typing`)
|
||||||
|
- Tin nhắn giữ chỗ + thay thế phản hồi cuối cùng
|
||||||
|
- Tự động tham gia phòng được mời (có thể tắt)
|
||||||
|
|
||||||
|
## 4. TODO
|
||||||
|
|
||||||
|
- Cải thiện metadata phương tiện phong phú (ví dụ kích thước và hình thu nhỏ hình ảnh/video)
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
> 返回 [README](../../../README.zh.md)
|
||||||
|
|
||||||
# Matrix 通道配置指南
|
# Matrix 通道配置指南
|
||||||
|
|
||||||
## 1. 配置示例
|
## 1. 配置示例
|
||||||
|
|
@ -42,6 +44,7 @@
|
||||||
| group_trigger | object | 否 | 群聊触发策略(支持 `mention_only` / `prefixes`) |
|
| group_trigger | object | 否 | 群聊触发策略(支持 `mention_only` / `prefixes`) |
|
||||||
| placeholder | object | 否 | 占位消息配置 |
|
| placeholder | object | 否 | 占位消息配置 |
|
||||||
| reasoning_channel_id | string | 否 | 思维链输出目标通道 |
|
| reasoning_channel_id | string | 否 | 思维链输出目标通道 |
|
||||||
|
| message_format | string | 否 | 消息格式:`richtext`(富文本)或 `plain`(纯文本) |
|
||||||
|
|
||||||
## 3. 当前支持
|
## 3. 当前支持
|
||||||
|
|
||||||
|
|
|
||||||
33
docs/channels/onebot/README.fr.md
Normal file
33
docs/channels/onebot/README.fr.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
> Retour au [README](../../../README.fr.md)
|
||||||
|
|
||||||
|
# OneBot
|
||||||
|
|
||||||
|
OneBot est un standard de protocole ouvert pour les bots QQ, fournissant une interface unifiée pour diverses implémentations de bots QQ (par exemple go-cqhttp, Mirai). Il utilise WebSocket pour la communication.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"onebot": {
|
||||||
|
"enabled": true,
|
||||||
|
"ws_url": "ws://localhost:8080",
|
||||||
|
"access_token": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Champ | Type | Requis | Description |
|
||||||
|
| ------------ | ------ | ------ | -------------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Oui | Activer ou non le canal OneBot |
|
||||||
|
| ws_url | string | Oui | URL WebSocket du serveur OneBot |
|
||||||
|
| access_token | string | Non | Jeton d'accès pour la connexion au serveur OneBot |
|
||||||
|
| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs |
|
||||||
|
|
||||||
|
## Procédure de configuration
|
||||||
|
|
||||||
|
1. Déployez une implémentation compatible OneBot (par exemple napcat)
|
||||||
|
2. Configurez l'implémentation OneBot pour activer le service WebSocket et définir un jeton d'accès (si nécessaire)
|
||||||
|
3. Renseignez l'URL WebSocket et le jeton d'accès dans le fichier de configuration
|
||||||
33
docs/channels/onebot/README.ja.md
Normal file
33
docs/channels/onebot/README.ja.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
> [README](../../../README.ja.md) に戻る
|
||||||
|
|
||||||
|
# OneBot
|
||||||
|
|
||||||
|
OneBot は QQ ボット向けのオープンプロトコル標準で、複数の QQ ボット実装(例: go-cqhttp、Mirai)に統一されたインターフェースを提供します。通信には WebSocket を使用します。
|
||||||
|
|
||||||
|
## 設定
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"onebot": {
|
||||||
|
"enabled": true,
|
||||||
|
"ws_url": "ws://localhost:8080",
|
||||||
|
"access_token": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| フィールド | 型 | 必須 | 説明 |
|
||||||
|
| ------------ | ------ | ------ | ---------------------------------------------------------------- |
|
||||||
|
| enabled | bool | はい | OneBot チャンネルを有効にするかどうか |
|
||||||
|
| ws_url | string | はい | OneBot サーバーの WebSocket URL |
|
||||||
|
| access_token | string | いいえ | OneBot サーバーへの接続に使用するアクセストークン |
|
||||||
|
| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 |
|
||||||
|
|
||||||
|
## セットアップ手順
|
||||||
|
|
||||||
|
1. OneBot 互換の実装(例: napcat)をデプロイする
|
||||||
|
2. OneBot 実装で WebSocket サービスを有効にし、アクセストークンを設定する(必要な場合)
|
||||||
|
3. WebSocket URL とアクセストークンを設定ファイルに入力する
|
||||||
33
docs/channels/onebot/README.md
Normal file
33
docs/channels/onebot/README.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
> Back to [README](../../../README.md)
|
||||||
|
|
||||||
|
# OneBot
|
||||||
|
|
||||||
|
OneBot is an open protocol standard for QQ bots, providing a unified interface for various QQ bot implementations (e.g. go-cqhttp, Mirai). It uses WebSocket for communication.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"onebot": {
|
||||||
|
"enabled": true,
|
||||||
|
"ws_url": "ws://localhost:8080",
|
||||||
|
"access_token": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| ------------ | ------ | -------- | ---------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Yes | Whether to enable the OneBot channel |
|
||||||
|
| ws_url | string | Yes | WebSocket URL of the OneBot server |
|
||||||
|
| access_token | string | No | Access token for connecting to the OneBot server |
|
||||||
|
| allow_from | array | No | User ID whitelist; empty means all users are allowed |
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Deploy a OneBot-compatible implementation (e.g. napcat)
|
||||||
|
2. Configure the OneBot implementation to enable the WebSocket service and set an access token (if needed)
|
||||||
|
3. Fill in the WebSocket URL and access token in the configuration file
|
||||||
33
docs/channels/onebot/README.pt-br.md
Normal file
33
docs/channels/onebot/README.pt-br.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
> Voltar ao [README](../../../README.pt-br.md)
|
||||||
|
|
||||||
|
# OneBot
|
||||||
|
|
||||||
|
OneBot é um padrão de protocolo aberto para bots QQ, fornecendo uma interface unificada para diversas implementações de bots QQ (ex.: go-cqhttp, Mirai). Utiliza WebSocket para comunicação.
|
||||||
|
|
||||||
|
## Configuração
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"onebot": {
|
||||||
|
"enabled": true,
|
||||||
|
"ws_url": "ws://localhost:8080",
|
||||||
|
"access_token": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Campo | Tipo | Obrigatório | Descrição |
|
||||||
|
| ------------ | ------ | ----------- | -------------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Sim | Se o canal OneBot deve ser habilitado |
|
||||||
|
| ws_url | string | Sim | URL WebSocket do servidor OneBot |
|
||||||
|
| access_token | string | Não | Token de acesso para conexão ao servidor OneBot |
|
||||||
|
| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos |
|
||||||
|
|
||||||
|
## Configuração passo a passo
|
||||||
|
|
||||||
|
1. Implante uma implementação compatível com OneBot (ex.: napcat)
|
||||||
|
2. Configure a implementação OneBot para habilitar o serviço WebSocket e definir um token de acesso (se necessário)
|
||||||
|
3. Preencha a URL WebSocket e o token de acesso no arquivo de configuração
|
||||||
33
docs/channels/onebot/README.vi.md
Normal file
33
docs/channels/onebot/README.vi.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
> Quay lại [README](../../../README.vi.md)
|
||||||
|
|
||||||
|
# OneBot
|
||||||
|
|
||||||
|
OneBot là tiêu chuẩn giao thức mở dành cho bot QQ, cung cấp giao diện thống nhất cho nhiều triển khai bot QQ khác nhau (ví dụ: go-cqhttp, Mirai). Nó sử dụng WebSocket để giao tiếp.
|
||||||
|
|
||||||
|
## Cấu hình
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"onebot": {
|
||||||
|
"enabled": true,
|
||||||
|
"ws_url": "ws://localhost:8080",
|
||||||
|
"access_token": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Trường | Kiểu | Bắt buộc | Mô tả |
|
||||||
|
| ------------ | ------ | -------- | -------------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Có | Có bật kênh OneBot hay không |
|
||||||
|
| ws_url | string | Có | URL WebSocket của máy chủ OneBot |
|
||||||
|
| access_token | string | Không | Token truy cập để kết nối với máy chủ OneBot |
|
||||||
|
| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả |
|
||||||
|
|
||||||
|
## Quy trình thiết lập
|
||||||
|
|
||||||
|
1. Triển khai một bản triển khai tương thích OneBot (ví dụ: napcat)
|
||||||
|
2. Cấu hình bản triển khai OneBot để bật dịch vụ WebSocket và đặt token truy cập (nếu cần)
|
||||||
|
3. Điền URL WebSocket và token truy cập vào file cấu hình
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
> 返回 [README](../../../README.zh.md)
|
||||||
|
|
||||||
# OneBot
|
# OneBot
|
||||||
|
|
||||||
OneBot 是一个面向 QQ 机器人的开放协议标准,为多种 QQ 机器人实现(例如 go-cqhttp、Mirai)提供了统一的接口。它使用 WebSocket 进行通信。
|
OneBot 是一个面向 QQ 机器人的开放协议标准,为多种 QQ 机器人实现(例如 go-cqhttp、Mirai)提供了统一的接口。它使用 WebSocket 进行通信。
|
||||||
|
|
|
||||||
54
docs/channels/qq/README.fr.md
Normal file
54
docs/channels/qq/README.fr.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
> Retour au [README](../../../README.fr.md)
|
||||||
|
|
||||||
|
# QQ
|
||||||
|
|
||||||
|
PicoClaw prend en charge QQ via l'API Bot officielle de la plateforme ouverte QQ.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"qq": {
|
||||||
|
"enabled": true,
|
||||||
|
"app_id": "YOUR_APP_ID",
|
||||||
|
"app_secret": "YOUR_APP_SECRET",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Champ | Type | Requis | Description |
|
||||||
|
| ---------- | ------ | ------ | --------------------------------------------------------------------------- |
|
||||||
|
| enabled | bool | Oui | Activer ou non le canal QQ |
|
||||||
|
| app_id | string | Oui | App ID de l'application bot QQ |
|
||||||
|
| app_secret | string | Oui | App Secret de l'application bot QQ |
|
||||||
|
| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs |
|
||||||
|
|
||||||
|
## Configuration initiale
|
||||||
|
|
||||||
|
### Configuration rapide (recommandée)
|
||||||
|
|
||||||
|
La plateforme ouverte QQ propose une entrée de création en un clic :
|
||||||
|
|
||||||
|
1. Ouvrir [QQ Bot Quick Create](https://q.qq.com/qqbot/openclaw/index.html) et se connecter en scannant le QR code
|
||||||
|
2. Le système crée automatiquement un bot — copier l'**App ID** et l'**App Secret**
|
||||||
|
3. Renseigner les identifiants dans le fichier de configuration PicoClaw
|
||||||
|
4. Exécuter `picoclaw gateway` pour démarrer le service
|
||||||
|
5. Ouvrir QQ et commencer à discuter avec le bot
|
||||||
|
|
||||||
|
> L'App Secret n'est affiché qu'une seule fois — sauvegardez-le immédiatement. Le consulter à nouveau forcera une réinitialisation.
|
||||||
|
>
|
||||||
|
> Les bots créés via l'entrée rapide sont réservés à l'usage personnel du créateur et ne prennent pas en charge les discussions de groupe. Pour la prise en charge des groupes, configurez le mode sandbox sur la [plateforme ouverte QQ](https://q.qq.com/).
|
||||||
|
|
||||||
|
### Configuration manuelle
|
||||||
|
|
||||||
|
1. Se connecter à la [plateforme ouverte QQ](https://q.qq.com/) avec son compte QQ et s'inscrire en tant que développeur
|
||||||
|
2. Créer un bot QQ et personnaliser son avatar et son nom
|
||||||
|
3. Obtenir l'**App ID** et l'**App Secret** dans les paramètres du bot
|
||||||
|
4. Renseigner les identifiants dans le fichier de configuration PicoClaw
|
||||||
|
5. Exécuter `picoclaw gateway` pour démarrer le service
|
||||||
|
6. Rechercher votre bot dans QQ et commencer à discuter
|
||||||
|
|
||||||
|
> Pendant le développement, il est recommandé d'activer le mode sandbox et d'y ajouter les utilisateurs et groupes de test pour le débogage.
|
||||||
54
docs/channels/qq/README.ja.md
Normal file
54
docs/channels/qq/README.ja.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
> [README](../../../README.ja.md) に戻る
|
||||||
|
|
||||||
|
# QQ
|
||||||
|
|
||||||
|
PicoClaw は QQ オープンプラットフォームの公式 Bot API を通じて QQ をサポートします。
|
||||||
|
|
||||||
|
## 設定
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"qq": {
|
||||||
|
"enabled": true,
|
||||||
|
"app_id": "YOUR_APP_ID",
|
||||||
|
"app_secret": "YOUR_APP_SECRET",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| フィールド | 型 | 必須 | 説明 |
|
||||||
|
| ---------- | ------ | ------ | ------------------------------------------------------------- |
|
||||||
|
| enabled | bool | はい | QQ チャンネルを有効にするかどうか |
|
||||||
|
| app_id | string | はい | QQ ボットアプリケーションの App ID |
|
||||||
|
| app_secret | string | はい | QQ ボットアプリケーションの App Secret |
|
||||||
|
| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 |
|
||||||
|
|
||||||
|
## セットアップ手順
|
||||||
|
|
||||||
|
### クイックセットアップ(推奨)
|
||||||
|
|
||||||
|
QQ オープンプラットフォームにはワンクリック作成エントリーが用意されています:
|
||||||
|
|
||||||
|
1. [QQ ボットクイック作成](https://q.qq.com/qqbot/openclaw/index.html) を開き、QR コードをスキャンしてログインする
|
||||||
|
2. システムが自動的にボットを作成するので、**App ID** と **App Secret** をコピーする
|
||||||
|
3. PicoClaw 設定ファイルに認証情報を入力する
|
||||||
|
4. `picoclaw gateway` を実行してサービスを起動する
|
||||||
|
5. QQ を開いてボットとの会話を始める
|
||||||
|
|
||||||
|
> App Secret は一度しか表示されません。すぐに保存してください。再度表示しようとすると強制的にリセットされます。
|
||||||
|
>
|
||||||
|
> クイックエントリーで作成したボットは作成者本人のみが使用でき、グループチャットには対応していません。グループチャット機能が必要な場合は、[QQ オープンプラットフォーム](https://q.qq.com/) でサンドボックスモードを設定してください。
|
||||||
|
|
||||||
|
### 手動セットアップ
|
||||||
|
|
||||||
|
1. QQ アカウントで [QQ オープンプラットフォーム](https://q.qq.com/) にログインし、開発者アカウントを登録する
|
||||||
|
2. QQ ボットを作成し、アバターと名前をカスタマイズする
|
||||||
|
3. ボット設定から **App ID** と **App Secret** を取得する
|
||||||
|
4. PicoClaw 設定ファイルに認証情報を入力する
|
||||||
|
5. `picoclaw gateway` を実行してサービスを起動する
|
||||||
|
6. QQ でボットを検索して会話を始める
|
||||||
|
|
||||||
|
> 開発段階ではサンドボックスモードを有効にし、テストユーザーとグループをサンドボックスに追加してデバッグすることを推奨します。
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue