Merge remote-tracking branch 'origin/main' into fix/shell-denylist-bypasses
This commit is contained in:
commit
e9a048001f
301 changed files with 38500 additions and 6228 deletions
|
|
@ -5,16 +5,13 @@
|
||||||
# ANTHROPIC_API_KEY=sk-ant-xxx
|
# ANTHROPIC_API_KEY=sk-ant-xxx
|
||||||
# OPENAI_API_KEY=sk-xxx
|
# OPENAI_API_KEY=sk-xxx
|
||||||
# GEMINI_API_KEY=xxx
|
# GEMINI_API_KEY=xxx
|
||||||
# CEREBRAS_API_KEY=xxx
|
# CLAUDE_CODE_OAUTH=xxx
|
||||||
|
|
||||||
# ── Chat Channel ──────────────────────────
|
# ── Chat Channel ──────────────────────────
|
||||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||||
# DISCORD_BOT_TOKEN=xxx
|
# DISCORD_BOT_TOKEN=xxx
|
||||||
# LINE_CHANNEL_SECRET=xxx
|
|
||||||
# LINE_CHANNEL_ACCESS_TOKEN=xxx
|
|
||||||
|
|
||||||
# ── Web Search (optional) ────────────────
|
# ── Web Search (optional) ────────────────
|
||||||
# BRAVE_SEARCH_API_KEY=BSA...
|
# BRAVE_SEARCH_API_KEY=BSA...
|
||||||
|
|
||||||
# ── Timezone ──────────────────────────────
|
# ── Timezone ──────────────────────────────
|
||||||
TZ=Asia/Tokyo
|
TZ=Asia/Shanghai
|
||||||
|
|
|
||||||
19
.github/workflows/pr.yml
vendored
19
.github/workflows/pr.yml
vendored
|
|
@ -24,6 +24,25 @@ jobs:
|
||||||
with:
|
with:
|
||||||
version: v2.10.1
|
version: v2.10.1
|
||||||
|
|
||||||
|
vuln_check:
|
||||||
|
name: Security Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Run Govulncheck
|
||||||
|
uses: golang/govulncheck-action@v1
|
||||||
|
with:
|
||||||
|
go-package: ./...
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Tests
|
name: Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
|
|
@ -17,6 +17,11 @@ on:
|
||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
upload_tos:
|
||||||
|
description: "Upload to Volcengine TOS"
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-tag:
|
create-tag:
|
||||||
|
|
@ -100,3 +105,12 @@ jobs:
|
||||||
gh release edit "${{ inputs.tag }}" \
|
gh release edit "${{ inputs.tag }}" \
|
||||||
--draft=${{ inputs.draft }} \
|
--draft=${{ inputs.draft }} \
|
||||||
--prerelease=${{ inputs.prerelease }}
|
--prerelease=${{ inputs.prerelease }}
|
||||||
|
|
||||||
|
upload-tos:
|
||||||
|
name: Upload to TOS
|
||||||
|
needs: release
|
||||||
|
if: ${{ inputs.upload_tos }}
|
||||||
|
uses: ./.github/workflows/upload-tos.yml
|
||||||
|
with:
|
||||||
|
tag: ${{ inputs.tag }}
|
||||||
|
secrets: inherit
|
||||||
|
|
|
||||||
49
.github/workflows/upload-tos.yml
vendored
Normal file
49
.github/workflows/upload-tos.yml
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
name: Upload to Volcengine TOS
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Release tag to download and upload (e.g. v0.2.0)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Release tag to download and upload"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
upload-tos:
|
||||||
|
name: Upload to Volcengine TOS
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download release assets
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
gh release download "${{ inputs.tag }}" \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--dir artifacts \
|
||||||
|
--pattern "*.tar.gz" \
|
||||||
|
--pattern "*.zip" \
|
||||||
|
--pattern "*.rpm" \
|
||||||
|
--pattern "*.deb"
|
||||||
|
|
||||||
|
- name: Upload to Volcengine TOS
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.VOLC_TOS_ACCESS_KEY }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.VOLC_TOS_SECRET_KEY }}
|
||||||
|
AWS_DEFAULT_REGION: cn-beijing
|
||||||
|
run: |
|
||||||
|
aws configure set default.s3.addressing_style virtual
|
||||||
|
TOS_ENDPOINT="https://tos-s3-cn-beijing.volces.com"
|
||||||
|
# Upload to versioned directory
|
||||||
|
aws s3 sync artifacts/ "s3://picoclaw-downloads/${{ inputs.tag }}/" \
|
||||||
|
--endpoint-url "$TOS_ENDPOINT"
|
||||||
|
# Upload to latest (overwrite)
|
||||||
|
aws s3 sync artifacts/ "s3://picoclaw-downloads/latest/" \
|
||||||
|
--endpoint-url "$TOS_ENDPOINT" \
|
||||||
|
--delete
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -38,9 +38,15 @@ ralph/
|
||||||
.ralph/
|
.ralph/
|
||||||
tasks/
|
tasks/
|
||||||
|
|
||||||
|
# Plans
|
||||||
|
docs/plans/
|
||||||
|
|
||||||
# Editors
|
# Editors
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# Added by goreleaser init:
|
# Added by goreleaser init:
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Windows Application Icon/Resource
|
||||||
|
*.syso
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ linters:
|
||||||
- containedctx
|
- containedctx
|
||||||
- cyclop
|
- cyclop
|
||||||
- depguard
|
- depguard
|
||||||
- dupl
|
|
||||||
- dupword
|
- dupword
|
||||||
- err113
|
- err113
|
||||||
- exhaustruct
|
- exhaustruct
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ version: 2
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
- go generate ./cmd/picoclaw
|
- go generate ./...
|
||||||
|
- go install github.com/tc-hib/go-winres@latest
|
||||||
|
- go-winres make --in cmd/picoclaw-launcher/winres/winres.json --out cmd/picoclaw-launcher/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
|
|
@ -30,14 +32,73 @@ builds:
|
||||||
- riscv64
|
- riscv64
|
||||||
- loong64
|
- loong64
|
||||||
- arm
|
- arm
|
||||||
|
goarm:
|
||||||
|
- "6"
|
||||||
|
- "7"
|
||||||
main: ./cmd/picoclaw
|
main: ./cmd/picoclaw
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
|
||||||
|
- id: picoclaw-launcher
|
||||||
|
binary: picoclaw-launcher
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
tags:
|
||||||
|
- stdjson
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
- darwin
|
||||||
|
- freebsd
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- riscv64
|
||||||
|
- loong64
|
||||||
|
- arm
|
||||||
|
goarm:
|
||||||
|
- "6"
|
||||||
|
- "7"
|
||||||
|
main: ./cmd/picoclaw-launcher
|
||||||
|
ignore:
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
|
|
||||||
|
- id: picoclaw-launcher-tui
|
||||||
|
binary: picoclaw-launcher-tui
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
tags:
|
||||||
|
- stdjson
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
- darwin
|
||||||
|
- freebsd
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- riscv64
|
||||||
|
- loong64
|
||||||
|
- arm
|
||||||
|
goarm:
|
||||||
|
- "6"
|
||||||
|
- "7"
|
||||||
|
main: ./cmd/picoclaw-launcher-tui
|
||||||
|
ignore:
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
|
|
||||||
dockers_v2:
|
dockers_v2:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
dockerfile: Dockerfile.goreleaser
|
dockerfile: docker/Dockerfile.goreleaser
|
||||||
|
extra_files:
|
||||||
|
- docker/entrypoint.sh
|
||||||
ids:
|
ids:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
images:
|
images:
|
||||||
|
|
@ -68,10 +129,13 @@ archives:
|
||||||
|
|
||||||
nfpms:
|
nfpms:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
|
builds:
|
||||||
|
- picoclaw
|
||||||
|
- picoclaw-launcher
|
||||||
|
- picoclaw-launcher-tui
|
||||||
package_name: picoclaw
|
package_name: picoclaw
|
||||||
file_name_template: >-
|
file_name_template: >-
|
||||||
{{ .PackageName }}_
|
{{ .PackageName }}_
|
||||||
{{- .Version }}_
|
|
||||||
{{- if eq .Arch "amd64" }}x86_64
|
{{- if eq .Arch "amd64" }}x86_64
|
||||||
{{- else if eq .Arch "arm64" }}aarch64
|
{{- else if eq .Arch "arm64" }}aarch64
|
||||||
{{- else if eq .Arch "arm" }}armv{{ .Arm }}
|
{{- else if eq .Arch "arm" }}armv{{ .Arm }}
|
||||||
|
|
|
||||||
|
|
@ -269,8 +269,8 @@ Once your PR is submitted, you can reach out to the assigned reviewers listed in
|
||||||
|Function| Reviewer|
|
|Function| Reviewer|
|
||||||
|--- |--- |
|
|--- |--- |
|
||||||
|Provider|@yinwm |
|
|Provider|@yinwm |
|
||||||
|Channel |@yinwm |
|
|Channel |@yinwm/@alexhoshina |
|
||||||
|Agent |@lxowalle|
|
|Agent |@lxowalle/@Zhaoyikaiii|
|
||||||
|Tools |@lxowalle|
|
|Tools |@lxowalle|
|
||||||
|SKill ||
|
|SKill ||
|
||||||
|MCP ||
|
|MCP ||
|
||||||
|
|
|
||||||
|
|
@ -268,8 +268,8 @@ Release 分支的保护级别高于 `main`,在任何情况下均不允许直
|
||||||
|Function| Reviewer|
|
|Function| Reviewer|
|
||||||
|--- |--- |
|
|--- |--- |
|
||||||
|Provider|@yinwm |
|
|Provider|@yinwm |
|
||||||
|Channel |@yinwm |
|
|Channel |@yinwm/@alexhoshina |
|
||||||
|Agent |@lxowalle|
|
|Agent |@lxowalle/@Zhaoyikaiii|
|
||||||
|Tools |@lxowalle|
|
|Tools |@lxowalle|
|
||||||
|SKill ||
|
|SKill ||
|
||||||
|MCP ||
|
|MCP ||
|
||||||
|
|
|
||||||
4
LICENSE
4
LICENSE
|
|
@ -19,7 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
PicoClaw is heavily inspired by and based on [nanobot](https://github.com/HKUDS/nanobot) by HKUDS.
|
|
||||||
|
|
|
||||||
118
Makefile
118
Makefile
|
|
@ -18,6 +18,28 @@ LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(G
|
||||||
GO?=CGO_ENABLED=0 go
|
GO?=CGO_ENABLED=0 go
|
||||||
GOFLAGS?=-v -tags stdjson
|
GOFLAGS?=-v -tags stdjson
|
||||||
|
|
||||||
|
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
|
||||||
|
#
|
||||||
|
# Bytes (octal): \004 \024 \000 \160 → little-endian 0x70001404
|
||||||
|
# 0x70000000 EF_MIPS_ARCH_32R2 MIPS32 Release 2
|
||||||
|
# 0x00001000 EF_MIPS_ABI_O32 O32 ABI
|
||||||
|
# 0x00000400 EF_MIPS_NAN2008 IEEE 754-2008 NaN encoding
|
||||||
|
# 0x00000004 EF_MIPS_CPIC PIC calling sequence
|
||||||
|
#
|
||||||
|
# Go's GOMIPS=softfloat emits no FP instructions, so the NaN mode is irrelevant
|
||||||
|
# at runtime — this is purely an ELF metadata fix to satisfy the kernel's check.
|
||||||
|
# patchelf cannot modify e_flags; dd at a fixed offset is the most portable way.
|
||||||
|
#
|
||||||
|
# Ref: https://codebrowser.dev/linux/linux/arch/mips/include/asm/elf.h.html
|
||||||
|
define PATCH_MIPS_FLAGS
|
||||||
|
@if [ -f "$(1)" ]; then \
|
||||||
|
printf '\004\024\000\160' | dd of=$(1) bs=1 seek=36 count=4 conv=notrunc 2>/dev/null || \
|
||||||
|
{ echo "Error: failed to patch MIPS e_flags for $(1)"; exit 1; }; \
|
||||||
|
else \
|
||||||
|
echo "Error: $(1) not found, cannot patch MIPS e_flags"; exit 1; \
|
||||||
|
fi
|
||||||
|
endef
|
||||||
|
|
||||||
# Golangci-lint
|
# Golangci-lint
|
||||||
GOLANGCI_LINT?=golangci-lint
|
GOLANGCI_LINT?=golangci-lint
|
||||||
|
|
||||||
|
|
@ -44,10 +66,14 @@ ifeq ($(UNAME_S),Linux)
|
||||||
ARCH=amd64
|
ARCH=amd64
|
||||||
else ifeq ($(UNAME_M),aarch64)
|
else ifeq ($(UNAME_M),aarch64)
|
||||||
ARCH=arm64
|
ARCH=arm64
|
||||||
|
else ifeq ($(UNAME_M),armv81)
|
||||||
|
ARCH=arm64
|
||||||
else ifeq ($(UNAME_M),loongarch64)
|
else ifeq ($(UNAME_M),loongarch64)
|
||||||
ARCH=loong64
|
ARCH=loong64
|
||||||
else ifeq ($(UNAME_M),riscv64)
|
else ifeq ($(UNAME_M),riscv64)
|
||||||
ARCH=riscv64
|
ARCH=riscv64
|
||||||
|
else ifeq ($(UNAME_M),mipsel)
|
||||||
|
ARCH=mipsle
|
||||||
else
|
else
|
||||||
ARCH=$(UNAME_M)
|
ARCH=$(UNAME_M)
|
||||||
endif
|
endif
|
||||||
|
|
@ -85,14 +111,62 @@ build: generate
|
||||||
@echo "Build complete: $(BINARY_PATH)"
|
@echo "Build complete: $(BINARY_PATH)"
|
||||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||||
|
build-whatsapp-native: generate
|
||||||
|
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||||
|
@echo "Building for multiple platforms..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
|
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||||
|
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
|
## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||||
|
@echo "Build complete"
|
||||||
|
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit)
|
||||||
|
build-linux-arm: generate
|
||||||
|
@echo "Building for linux/arm (GOARM=7)..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
|
||||||
|
|
||||||
|
## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
|
||||||
|
build-linux-arm64: generate
|
||||||
|
@echo "Building for linux/arm64..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
||||||
|
|
||||||
|
## build-linux-mipsle: Build for Linux MIPS32 LE
|
||||||
|
build-linux-mipsle: generate
|
||||||
|
@echo "Building for linux/mipsle (softfloat)..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
|
||||||
|
|
||||||
|
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
|
||||||
|
build-pi-zero: build-linux-arm build-linux-arm64
|
||||||
|
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
|
||||||
|
|
||||||
## build-all: Build picoclaw for all platforms
|
## build-all: Build picoclaw for all platforms
|
||||||
build-all: generate
|
build-all: generate
|
||||||
@echo "Building for multiple platforms..."
|
@echo "Building for multiple platforms..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
||||||
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||||
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
@echo "All builds complete"
|
@echo "All builds complete"
|
||||||
|
|
@ -130,11 +204,11 @@ clean:
|
||||||
@echo "Clean complete"
|
@echo "Clean complete"
|
||||||
|
|
||||||
## vet: Run go vet for static analysis
|
## vet: Run go vet for static analysis
|
||||||
vet:
|
vet: generate
|
||||||
@$(GO) vet ./...
|
@$(GO) vet ./...
|
||||||
|
|
||||||
## test: Test Go code
|
## test: Test Go code
|
||||||
test:
|
test: generate
|
||||||
@$(GO) test ./...
|
@$(GO) test ./...
|
||||||
|
|
||||||
## fmt: Format Go code
|
## fmt: Format Go code
|
||||||
|
|
@ -166,6 +240,44 @@ check: deps fmt vet test
|
||||||
run: build
|
run: build
|
||||||
@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
|
@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
|
||||||
|
|
||||||
|
## docker-build: Build Docker image (minimal Alpine-based)
|
||||||
|
docker-build:
|
||||||
|
@echo "Building minimal Docker image (Alpine-based)..."
|
||||||
|
docker compose -f docker/docker-compose.yml build picoclaw-agent picoclaw-gateway
|
||||||
|
|
||||||
|
## docker-build-full: Build Docker image with full MCP support (Node.js 24)
|
||||||
|
docker-build-full:
|
||||||
|
@echo "Building full-featured Docker image (Node.js 24)..."
|
||||||
|
docker compose -f docker/docker-compose.full.yml build picoclaw-agent picoclaw-gateway
|
||||||
|
|
||||||
|
## docker-test: Test MCP tools in Docker container
|
||||||
|
docker-test:
|
||||||
|
@echo "Testing MCP tools in Docker..."
|
||||||
|
@chmod +x scripts/test-docker-mcp.sh
|
||||||
|
@./scripts/test-docker-mcp.sh
|
||||||
|
|
||||||
|
## docker-run: Run picoclaw gateway in Docker (Alpine-based)
|
||||||
|
docker-run:
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
|
|
||||||
|
## docker-run-full: Run picoclaw gateway in Docker (full-featured)
|
||||||
|
docker-run-full:
|
||||||
|
docker compose -f docker/docker-compose.full.yml --profile gateway up
|
||||||
|
|
||||||
|
## docker-run-agent: Run picoclaw agent in Docker (interactive, Alpine-based)
|
||||||
|
docker-run-agent:
|
||||||
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
|
|
||||||
|
## docker-run-agent-full: Run picoclaw agent in Docker (interactive, full-featured)
|
||||||
|
docker-run-agent-full:
|
||||||
|
docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent
|
||||||
|
|
||||||
|
## docker-clean: Clean Docker images and volumes
|
||||||
|
docker-clean:
|
||||||
|
docker compose -f docker/docker-compose.yml down -v
|
||||||
|
docker compose -f docker/docker-compose.full.yml down -v
|
||||||
|
docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true
|
||||||
|
|
||||||
## help: Show this help message
|
## help: Show this help message
|
||||||
help:
|
help:
|
||||||
@echo "picoclaw Makefile"
|
@echo "picoclaw Makefile"
|
||||||
|
|
@ -181,6 +293,8 @@ help:
|
||||||
@echo " make install # Install to ~/.local/bin"
|
@echo " make install # Install to ~/.local/bin"
|
||||||
@echo " make uninstall # Remove from /usr/local/bin"
|
@echo " make uninstall # Remove from /usr/local/bin"
|
||||||
@echo " make install-skills # Install skills to workspace"
|
@echo " make install-skills # Install skills to workspace"
|
||||||
|
@echo " make docker-build # Build minimal Docker image"
|
||||||
|
@echo " make docker-test # Test MCP tools in Docker"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Environment Variables:"
|
@echo "Environment Variables:"
|
||||||
@echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)"
|
@echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)"
|
||||||
|
|
|
||||||
142
README.fr.md
142
README.fr.md
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<br>
|
||||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
|
|
||||||
⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz.
|
⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz.
|
||||||
|
|
||||||
🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM et x86. Un clic et c'est parti !
|
🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM, MIPS et x86. Un clic et c'est parti !
|
||||||
|
|
||||||
🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle.
|
🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle.
|
||||||
|
|
||||||
|
|
@ -164,39 +164,43 @@ Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installe
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Configurez vos clés API
|
# 2. Premier lancement — génère docker/data/config.json puis s'arrête
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc.
|
# Le conteneur affiche "First-run setup complete." puis s'arrête.
|
||||||
|
|
||||||
# 3. Compiler & Démarrer
|
# 3. Configurez vos clés API
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # Clés API du fournisseur, tokens de bot, etc.
|
||||||
|
|
||||||
|
# 4. Démarrer
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`.
|
> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Voir les logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
# 4. Voir les logs
|
# 6. Arrêter
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
|
|
||||||
# 5. Arrêter
|
|
||||||
docker compose --profile gateway down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Mode Agent (exécution unique)
|
### Mode Agent (exécution unique)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Poser une question
|
# Poser une question
|
||||||
docker compose run --rm picoclaw-agent -m "Combien font 2+2 ?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Combien font 2+2 ?"
|
||||||
|
|
||||||
# Mode interactif
|
# Mode interactif
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Recompiler
|
### Mettre à jour
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Démarrage Rapide
|
### 🚀 Démarrage Rapide
|
||||||
|
|
@ -221,6 +225,7 @@ picoclaw onboard
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -252,6 +257,9 @@ picoclaw onboard
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Nouveau** : Le format de configuration `model_list` permet d'ajouter des fournisseurs sans modifier le code. Voir [Configuration de Modèle](#configuration-de-modèle-model_list) pour plus de détails.
|
||||||
|
> `request_timeout` est optionnel et s'exprime en secondes. S'il est omis ou défini à `<= 0`, PicoClaw utilise le délai d'expiration par défaut (120s).
|
||||||
|
|
||||||
**3. Obtenir des Clés API**
|
**3. Obtenir des Clés API**
|
||||||
|
|
||||||
* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
|
|
@ -280,7 +288,7 @@ Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom
|
||||||
| **QQ** | Facile (AppID + AppSecret) |
|
| **QQ** | Facile (AppID + AppSecret) |
|
||||||
| **DingTalk** | Moyen (identifiants de l'application) |
|
| **DingTalk** | Moyen (identifiants de l'application) |
|
||||||
| **LINE** | Moyen (identifiants + URL de webhook) |
|
| **LINE** | Moyen (identifiants + URL de webhook) |
|
||||||
| **WeCom** | Moyen (CorpID + configuration webhook) |
|
| **WeCom AI Bot** | Moyen (Token + clé AES) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recommandé)</summary>
|
<summary><b>Telegram</b> (Recommandé)</summary>
|
||||||
|
|
@ -448,8 +456,6 @@ picoclaw gateway
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"channel_secret": "VOTRE_CHANNEL_SECRET",
|
"channel_secret": "VOTRE_CHANNEL_SECRET",
|
||||||
"channel_access_token": "VOTRE_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "VOTRE_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18791,
|
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -462,12 +468,14 @@ picoclaw gateway
|
||||||
LINE exige HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel :
|
LINE exige HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Exemple avec ngrok
|
# Exemple avec ngrok (tunnel vers le serveur Gateway partagé)
|
||||||
ngrok http 18791
|
ngrok http 18790
|
||||||
```
|
```
|
||||||
|
|
||||||
Puis configurez l'URL du Webhook dans la LINE Developers Console sur `https://votre-domaine/webhook/line` et activez **Use webhook**.
|
Puis configurez l'URL du Webhook dans la LINE Developers Console sur `https://votre-domaine/webhook/line` et activez **Use webhook**.
|
||||||
|
|
||||||
|
> **Note** : Le webhook LINE est servi par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Si vous utilisez ngrok ou un proxy inverse, faites pointer le tunnel vers le port `18790`.
|
||||||
|
|
||||||
**4. Lancer**
|
**4. Lancer**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -476,19 +484,20 @@ picoclaw gateway
|
||||||
|
|
||||||
> Dans les discussions de groupe, le bot répond uniquement lorsqu'il est mentionné avec @. Les réponses citent le message original.
|
> Dans les discussions de groupe, le bot répond uniquement lorsqu'il est mentionné avec @. Les réponses citent le message original.
|
||||||
|
|
||||||
> **Docker Compose** : Ajoutez `ports: ["18791:18791"]` au service `picoclaw-gateway` pour exposer le port du webhook.
|
> **Docker Compose** : Si vous avez besoin d'exposer le webhook LINE via Docker, mappez le port du Gateway partagé (par défaut `18790`) vers l'hôte, par exemple `ports: ["18790:18790"]`. Notez que le serveur Gateway sert les webhooks de tous les canaux à partir de ce port.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>WeCom (WeChat Work)</b></summary>
|
<summary><b>WeCom (WeChat Work)</b></summary>
|
||||||
|
|
||||||
PicoClaw prend en charge deux types d'intégration WeCom :
|
PicoClaw prend en charge trois types d'intégration WeCom :
|
||||||
|
|
||||||
**Option 1 : WeCom Bot (Robot Intelligent)** - Configuration plus facile, prend en charge les discussions de groupe
|
**Option 1 : WeCom Bot (Robot)** - Configuration plus facile, prend en charge les discussions de groupe
|
||||||
**Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive
|
**Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive, chat privé uniquement
|
||||||
|
**Option 3 : WeCom AI Bot (Bot Intelligent)** - Bot IA officiel, réponses en streaming, prend en charge groupe et privé
|
||||||
|
|
||||||
Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour des instructions détaillées.
|
Voir le [Guide de Configuration WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) pour des instructions détaillées.
|
||||||
|
|
||||||
**Configuration Rapide - WeCom Bot :**
|
**Configuration Rapide - WeCom Bot :**
|
||||||
|
|
||||||
|
|
@ -507,8 +516,6 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18793,
|
|
||||||
"webhook_path": "/webhook/wecom",
|
"webhook_path": "/webhook/wecom",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -527,7 +534,7 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour
|
||||||
**2. Configurer la réception des messages**
|
**2. Configurer la réception des messages**
|
||||||
|
|
||||||
* Dans les détails de l'application, cliquez sur "Recevoir les Messages" → "Configurer l'API"
|
* Dans les détails de l'application, cliquez sur "Recevoir les Messages" → "Configurer l'API"
|
||||||
* Définissez l'URL sur `http://your-server:18792/webhook/wecom-app`
|
* Définissez l'URL sur `http://your-server:18790/webhook/wecom-app`
|
||||||
* Générez le **Token** et l'**EncodingAESKey**
|
* Générez le **Token** et l'**EncodingAESKey**
|
||||||
|
|
||||||
**3. Configurer**
|
**3. Configurer**
|
||||||
|
|
@ -542,8 +549,6 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour
|
||||||
"agent_id": 1000002,
|
"agent_id": 1000002,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
"webhook_path": "/webhook/wecom-app",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -557,7 +562,40 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note** : WeCom App nécessite l'ouverture du port 18792 pour les callbacks webhook. Utilisez un proxy inverse pour HTTPS en production.
|
> **Note** : Les callbacks webhook WeCom App sont servis par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Assurez-vous que le port `18790` est accessible ou utilisez un proxy inverse HTTPS en production.
|
||||||
|
|
||||||
|
**Configuration Rapide - WeCom AI Bot :**
|
||||||
|
|
||||||
|
**1. Créer un AI Bot**
|
||||||
|
|
||||||
|
* Accédez à la Console d'Administration WeCom → Gestion des Applications → AI Bot
|
||||||
|
* Configurez l'URL de callback : `http://your-server:18791/webhook/wecom-aibot`
|
||||||
|
* Copiez le **Token** et générez l'**EncodingAESKey**
|
||||||
|
|
||||||
|
**2. Configurer**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_aibot": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
"allow_from": [],
|
||||||
|
"welcome_message": "Bonjour ! Comment puis-je vous aider ?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Lancer**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note** : WeCom AI Bot utilise le protocole pull en streaming — pas de problème de timeout. Les tâches longues (>5,5 min) basculent automatiquement vers la livraison via `response_url`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -571,6 +609,31 @@ Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul mes
|
||||||
|
|
||||||
Fichier de configuration : `~/.picoclaw/config.json`
|
Fichier de configuration : `~/.picoclaw/config.json`
|
||||||
|
|
||||||
|
### Variables d'Environnement
|
||||||
|
|
||||||
|
Vous pouvez remplacer les chemins par défaut à l'aide de variables d'environnement. Ceci est utile pour les installations portables, les déploiements conteneurisés ou l'exécution de picoclaw en tant que service système. Ces variables sont indépendantes et contrôlent différents chemins.
|
||||||
|
|
||||||
|
| Variable | Description | Chemin par Défaut |
|
||||||
|
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||||
|
| `PICOCLAW_CONFIG` | Remplace le chemin du fichier de configuration. Cela indique directement à picoclaw quel `config.json` charger, en ignorant tous les autres emplacements. | `~/.picoclaw/config.json` |
|
||||||
|
| `PICOCLAW_HOME` | Remplace le répertoire racine des données picoclaw. Cela modifie l'emplacement par défaut du `workspace` et des autres répertoires de données. | `~/.picoclaw` |
|
||||||
|
|
||||||
|
**Exemples :**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Exécuter picoclaw en utilisant un fichier de configuration spécifique
|
||||||
|
# Le chemin du workspace sera lu à partir de ce fichier de configuration
|
||||||
|
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||||
|
|
||||||
|
# Exécuter picoclaw avec toutes ses données stockées dans /opt/picoclaw
|
||||||
|
# La configuration sera chargée à partir du fichier par défaut ~/.picoclaw/config.json
|
||||||
|
# Le workspace sera créé dans /opt/picoclaw/workspace
|
||||||
|
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
|
|
||||||
|
# Utiliser les deux pour une configuration entièrement personnalisée
|
||||||
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
### Structure du Workspace
|
### Structure du Workspace
|
||||||
|
|
||||||
PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) :
|
PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) :
|
||||||
|
|
@ -764,7 +827,7 @@ Le sous-agent a accès aux outils (message, web_search, etc.) et peut communique
|
||||||
### Fournisseurs
|
### Fournisseurs
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages vocaux Telegram seront automatiquement transcrits.
|
> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages audio de n'importe quel canal seront automatiquement transcrits au niveau de l'agent.
|
||||||
|
|
||||||
| Fournisseur | Utilisation | Obtenir une Clé API |
|
| Fournisseur | Utilisation | Obtenir une Clé API |
|
||||||
| ------------------------ | ---------------------------------------- | ------------------------------------------------------ |
|
| ------------------------ | ---------------------------------------- | ------------------------------------------------------ |
|
||||||
|
|
@ -979,6 +1042,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
||||||
```
|
```
|
||||||
> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
|
> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
|
||||||
|
|
||||||
|
**Proxy/API personnalisée**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Équilibrage de Charge
|
#### Équilibrage de Charge
|
||||||
|
|
||||||
Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :
|
Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :
|
||||||
|
|
|
||||||
142
README.ja.md
142
README.ja.md
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
|
|
||||||
⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。
|
⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。
|
||||||
|
|
||||||
🌍 **真のポータビリティ**: RISC-V、ARM、x86 対応の単一バイナリ。ワンクリックで Go!
|
🌍 **真のポータビリティ**: RISC-V、ARM、MIPS、x86 対応の単一バイナリ。ワンクリックで Go!
|
||||||
|
|
||||||
🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。
|
🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。
|
||||||
|
|
||||||
|
|
@ -126,39 +126,43 @@ Docker Compose を使えば、ローカルにインストールせずに PicoCla
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. API キーを設定
|
# 2. 初回起動 — docker/data/config.json を自動生成して終了
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キーを設定
|
# コンテナが "First-run setup complete." を表示して停止します。
|
||||||
|
|
||||||
# 3. ビルドと起動
|
# 3. API キーを設定
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # プロバイダー API キー、Bot トークンなどを設定
|
||||||
|
|
||||||
|
# 4. 起動
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。
|
> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. ログ確認
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
# 4. ログ確認
|
# 6. 停止
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
|
|
||||||
# 5. 停止
|
|
||||||
docker compose --profile gateway down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent モード(ワンショット)
|
### Agent モード(ワンショット)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 質問を投げる
|
# 質問を投げる
|
||||||
docker compose run --rm picoclaw-agent -m "What is 2+2?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
|
||||||
|
|
||||||
# インタラクティブモード
|
# インタラクティブモード
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### リビルド
|
### アップデート
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 クイックスタート(ネイティブ)
|
### 🚀 クイックスタート(ネイティブ)
|
||||||
|
|
@ -183,6 +187,7 @@ picoclaw onboard
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -221,6 +226,9 @@ picoclaw onboard
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **新機能**: `model_list` 形式により、プロバイダーをコード変更なしで追加できます。詳細は [モデル設定](#モデル設定-model_list) を参照してください。
|
||||||
|
> `request_timeout` は任意の秒単位設定です。省略または `<= 0` の場合、PicoClaw はデフォルトのタイムアウト(120秒)を使用します。
|
||||||
|
|
||||||
**3. API キーの取得**
|
**3. API キーの取得**
|
||||||
|
|
||||||
- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
|
|
@ -249,7 +257,7 @@ Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話でき
|
||||||
| **QQ** | 簡単(AppID + AppSecret) |
|
| **QQ** | 簡単(AppID + AppSecret) |
|
||||||
| **DingTalk** | 普通(アプリ認証情報) |
|
| **DingTalk** | 普通(アプリ認証情報) |
|
||||||
| **LINE** | 普通(認証情報 + Webhook URL) |
|
| **LINE** | 普通(認証情報 + Webhook URL) |
|
||||||
| **WeCom** | 普通(CorpID + Webhook設定) |
|
| **WeCom AI Bot** | 普通(Token + AES キー) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b>(推奨)</summary>
|
<summary><b>Telegram</b>(推奨)</summary>
|
||||||
|
|
@ -413,8 +421,6 @@ picoclaw gateway
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18791,
|
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -428,11 +434,13 @@ LINE の Webhook には HTTPS が必要です。リバースプロキシまた
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ngrok の例
|
# ngrok の例
|
||||||
ngrok http 18791
|
ngrok http 18790
|
||||||
```
|
```
|
||||||
|
|
||||||
LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。
|
LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。
|
||||||
|
|
||||||
|
> **注意**: LINE の Webhook は共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は Gateway のポートを公開するか、リバースプロキシを設定してください。
|
||||||
|
|
||||||
**4. 起動**
|
**4. 起動**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -441,19 +449,20 @@ picoclaw gateway
|
||||||
|
|
||||||
> グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。
|
> グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。
|
||||||
|
|
||||||
> **Docker Compose**: `picoclaw-gateway` サービスに `ports: ["18791:18791"]` を追加して Webhook ポートを公開してください。
|
> **Docker Compose**: Gateway HTTP サーバーは共有の `127.0.0.1:18790` で Webhook を提供します。ホストからアクセスするには `picoclaw-gateway` サービスに `ports: ["18790:18790"]` を追加してください。
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>WeCom (企業微信)</b></summary>
|
<summary><b>WeCom (企業微信)</b></summary>
|
||||||
|
|
||||||
PicoClaw は2種類の WeCom 統合をサポートしています:
|
PicoClaw は3種類の WeCom 統合をサポートしています:
|
||||||
|
|
||||||
**オプション1: WeCom Bot (智能ロボット)** - 簡単な設定、グループチャット対応
|
**オプション1: WeCom Bot (ロボット)** - 簡単な設定、グループチャット対応
|
||||||
**オプション2: WeCom App (自作アプリ)** - より多機能、アクティブメッセージング対応
|
**オプション2: WeCom App (カスタムアプリ)** - より多機能、アクティブメッセージング対応、プライベートチャットのみ
|
||||||
|
**オプション3: WeCom AI Bot (スマートボット)** - 公式 AI Bot、ストリーミング返信、グループ・プライベート両対応
|
||||||
|
|
||||||
詳細な設定手順は [WeCom App Configuration Guide](docs/wecom-app-configuration.md) を参照してください。
|
詳細な設定手順は [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) を参照してください。
|
||||||
|
|
||||||
**クイックセットアップ - WeCom Bot:**
|
**クイックセットアップ - WeCom Bot:**
|
||||||
|
|
||||||
|
|
@ -472,13 +481,13 @@ PicoClaw は2種類の WeCom 統合をサポートしています:
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18793,
|
|
||||||
"webhook_path": "/webhook/wecom",
|
"webhook_path": "/webhook/wecom",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> **注意**: WeCom Bot の Webhook 受信は共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は Gateway のポートを公開するか、HTTPS 用のリバースプロキシを設定してください。
|
||||||
```
|
```
|
||||||
|
|
||||||
**クイックセットアップ - WeCom App:**
|
**クイックセットアップ - WeCom App:**
|
||||||
|
|
@ -492,7 +501,7 @@ PicoClaw は2種類の WeCom 統合をサポートしています:
|
||||||
**2. メッセージ受信を設定**
|
**2. メッセージ受信を設定**
|
||||||
|
|
||||||
* アプリ詳細で "メッセージを受信" → "APIを設定" をクリック
|
* アプリ詳細で "メッセージを受信" → "APIを設定" をクリック
|
||||||
* URL を `http://your-server:18792/webhook/wecom-app` に設定
|
* URL を `http://your-server:18790/webhook/wecom-app` に設定
|
||||||
* **Token** と **EncodingAESKey** を生成
|
* **Token** と **EncodingAESKey** を生成
|
||||||
|
|
||||||
**3. 設定**
|
**3. 設定**
|
||||||
|
|
@ -507,8 +516,6 @@ PicoClaw は2種類の WeCom 統合をサポートしています:
|
||||||
"agent_id": 1000002,
|
"agent_id": 1000002,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
"webhook_path": "/webhook/wecom-app",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -522,7 +529,40 @@ PicoClaw は2種類の WeCom 統合をサポートしています:
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
> **注意**: WeCom App は Webhook コールバック用にポート 18792 を開放する必要があります。本番環境では HTTPS 用のリバースプロキシを使用してください。
|
> **注意**: WeCom App の Webhook コールバックは共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は HTTPS 用のリバースプロキシを設定してください。
|
||||||
|
|
||||||
|
**クイックセットアップ - WeCom AI Bot:**
|
||||||
|
|
||||||
|
**1. AI Bot を作成**
|
||||||
|
|
||||||
|
* WeCom 管理コンソール → アプリ管理 → AI Bot
|
||||||
|
* コールバック URL を設定: `http://your-server:18791/webhook/wecom-aibot`
|
||||||
|
* **Token** をコピーし、**EncodingAESKey** を生成
|
||||||
|
|
||||||
|
**2. 設定**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_aibot": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
"allow_from": [],
|
||||||
|
"welcome_message": "こんにちは!何かお手伝いできますか?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 起動**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**: WeCom AI Bot はストリーミングプルプロトコルを使用 — 返信タイムアウトの心配なし。長時間タスク(>30秒)は自動的に `response_url` によるプッシュ配信に切り替わります。
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -530,6 +570,31 @@ picoclaw gateway
|
||||||
|
|
||||||
設定ファイル: `~/.picoclaw/config.json`
|
設定ファイル: `~/.picoclaw/config.json`
|
||||||
|
|
||||||
|
### 環境変数
|
||||||
|
|
||||||
|
環境変数を使用してデフォルトのパスを上書きできます。これは、ポータブルインストール、コンテナ化されたデプロイメント、または picoclaw をシステムサービスとして実行する場合に便利です。これらの変数は独立しており、異なるパスを制御します。
|
||||||
|
|
||||||
|
| 変数 | 説明 | デフォルトパス |
|
||||||
|
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||||
|
| `PICOCLAW_CONFIG` | 設定ファイルへのパスを上書きします。これにより、picoclaw は他のすべての場所を無視して、指定された `config.json` をロードします。 | `~/.picoclaw/config.json` |
|
||||||
|
| `PICOCLAW_HOME` | picoclaw データのルートディレクトリを上書きします。これにより、`workspace` やその他のデータディレクトリのデフォルトの場所が変更されます。 | `~/.picoclaw` |
|
||||||
|
|
||||||
|
**例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 特定の設定ファイルを使用して picoclaw を実行する
|
||||||
|
# ワークスペースのパスはその設定ファイル内から読み込まれます
|
||||||
|
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||||
|
|
||||||
|
# すべてのデータを /opt/picoclaw に保存して picoclaw を実行する
|
||||||
|
# 設定はデフォルトの ~/.picoclaw/config.json からロードされます
|
||||||
|
# ワークスペースは /opt/picoclaw/workspace に作成されます
|
||||||
|
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
|
|
||||||
|
# 両方を使用して完全にカスタマイズされたセットアップを行う
|
||||||
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
### ワークスペース構成
|
### ワークスペース構成
|
||||||
|
|
||||||
PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します:
|
PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します:
|
||||||
|
|
@ -720,7 +785,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
### プロバイダー
|
### プロバイダー
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、Telegram の音声メッセージが自動的に文字起こしされます。
|
> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、あらゆるチャンネルからの音声メッセージがエージェントレベルで自動的に文字起こしされます。
|
||||||
|
|
||||||
| プロバイダー | 用途 | API キー取得先 |
|
| プロバイダー | 用途 | API キー取得先 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
@ -918,6 +983,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
```
|
```
|
||||||
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
|
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
|
||||||
|
|
||||||
|
**カスタムプロキシ/API**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### ロードバランシング
|
#### ロードバランシング
|
||||||
|
|
||||||
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
|
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
|
||||||
|
|
|
||||||
377
README.md
377
README.md
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<br>
|
||||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -54,7 +54,7 @@
|
||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/ROADMAP.md) —we can’t wait to have you on board!
|
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](ROADMAP.md) —we can’t wait to have you on board!
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
||||||
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
|
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
|
|
||||||
⚡️ **Lightning Fast**: 400X Faster startup time, boot in 1 second even in 0.6GHz single core.
|
⚡️ **Lightning Fast**: 400X Faster startup time, boot in 1 second even in 0.6GHz single core.
|
||||||
|
|
||||||
🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, and x86, One-click to Go!
|
🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, MIPS, and x86, One-click to Go!
|
||||||
|
|
||||||
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
|
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
|
||||||
|
|
||||||
|
|
@ -154,10 +154,15 @@ make build
|
||||||
# Build for multiple platforms
|
# Build for multiple platforms
|
||||||
make build-all
|
make build-all
|
||||||
|
|
||||||
|
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||||
|
make build-pi-zero
|
||||||
|
|
||||||
# Build And Install
|
# Build And Install
|
||||||
make install
|
make install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm` (output: `build/picoclaw-linux-arm`); 64-bit → `make build-linux-arm64` (output: `build/picoclaw-linux-arm64`). Or run `make build-pi-zero` to build both.
|
||||||
|
|
||||||
## 🐳 Docker Compose
|
## 🐳 Docker Compose
|
||||||
|
|
||||||
You can also run PicoClaw using Docker Compose without installing anything locally.
|
You can also run PicoClaw using Docker Compose without installing anything locally.
|
||||||
|
|
@ -167,39 +172,43 @@ You can also run PicoClaw using Docker Compose without installing anything local
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Set your API keys
|
# 2. First run — auto-generates docker/data/config.json then exits
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc.
|
# The container prints "First-run setup complete." and stops.
|
||||||
|
|
||||||
# 3. Build & Start
|
# 3. Set your API keys
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # Set provider API keys, bot tokens, etc.
|
||||||
|
|
||||||
|
# 4. Start
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
|
> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Check logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
# 4. Check logs
|
# 6. Stop
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
|
|
||||||
# 5. Stop
|
|
||||||
docker compose --profile gateway down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent Mode (One-shot)
|
### Agent Mode (One-shot)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ask a question
|
# Ask a question
|
||||||
docker compose run --rm picoclaw-agent -m "What is 2+2?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
|
||||||
|
|
||||||
# Interactive mode
|
# Interactive mode
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rebuild
|
### Update
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Quick Start
|
### 🚀 Quick Start
|
||||||
|
|
@ -207,7 +216,7 @@ docker compose --profile gateway up -d
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Set your API key in `~/.picoclaw/config.json`.
|
> Set your API key in `~/.picoclaw/config.json`.
|
||||||
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||||
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month), [SearXNG](https://github.com/searxng/searxng) (free, self-hosted) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
||||||
|
|
||||||
**1. Initialize**
|
**1. Initialize**
|
||||||
|
|
||||||
|
|
@ -232,7 +241,8 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "your-api-key"
|
"api_key": "your-api-key",
|
||||||
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
|
@ -255,6 +265,16 @@ picoclaw onboard
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_PERPLEXITY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://your-searxng-instance:8888",
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -262,11 +282,17 @@ picoclaw onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
|
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
|
||||||
|
> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).
|
||||||
|
|
||||||
**3. Get API Keys**
|
**3. Get API Keys**
|
||||||
|
|
||||||
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
* **Web Search** (optional): [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) · [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
* **Web Search** (optional):
|
||||||
|
* [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month)
|
||||||
|
* [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface
|
||||||
|
* [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed)
|
||||||
|
* [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month)
|
||||||
|
* DuckDuckGo - Built-in fallback (no API key required)
|
||||||
|
|
||||||
> **Note**: See `config.example.json` for a complete configuration template.
|
> **Note**: See `config.example.json` for a complete configuration template.
|
||||||
|
|
||||||
|
|
@ -282,16 +308,19 @@ That's it! You have a working AI assistant in 2 minutes.
|
||||||
|
|
||||||
## 💬 Chat Apps
|
## 💬 Chat Apps
|
||||||
|
|
||||||
Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
|
Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or WeCom
|
||||||
|
|
||||||
|
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
|
||||||
|
|
||||||
| Channel | Setup |
|
| Channel | Setup |
|
||||||
| ------------ | ---------------------------------- |
|
| ------------ | ---------------------------------- |
|
||||||
| **Telegram** | Easy (just a token) |
|
| **Telegram** | Easy (just a token) |
|
||||||
| **Discord** | Easy (bot token + intents) |
|
| **Discord** | Easy (bot token + intents) |
|
||||||
|
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
|
||||||
| **QQ** | Easy (AppID + AppSecret) |
|
| **QQ** | Easy (AppID + AppSecret) |
|
||||||
| **DingTalk** | Medium (app credentials) |
|
| **DingTalk** | Medium (app credentials) |
|
||||||
| **LINE** | Medium (credentials + webhook URL) |
|
| **LINE** | Medium (credentials + webhook URL) |
|
||||||
| **WeCom** | Medium (CorpID + webhook setup) |
|
| **WeCom AI Bot** | Medium (Token + AES key) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recommended)</summary>
|
<summary><b>Telegram</b> (Recommended)</summary>
|
||||||
|
|
@ -324,6 +353,13 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**4. Telegram command menu (auto-registered at startup)**
|
||||||
|
|
||||||
|
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.
|
||||||
|
Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
|
||||||
|
|
||||||
|
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
@ -352,8 +388,7 @@ picoclaw gateway
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"]
|
||||||
"mention_only": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -366,9 +401,31 @@ picoclaw gateway
|
||||||
* Bot Permissions: `Send Messages`, `Read Message History`
|
* Bot Permissions: `Send Messages`, `Read Message History`
|
||||||
* Open the generated invite URL and add the bot to your server
|
* Open the generated invite URL and add the bot to your server
|
||||||
|
|
||||||
**Optional: Mention-only mode**
|
**Optional: Group trigger mode**
|
||||||
|
|
||||||
Set `"mention_only": true` to make the bot respond only when @-mentioned. Useful for shared servers where you want the bot to respond only when explicitly called.
|
By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"discord": {
|
||||||
|
"group_trigger": { "mention_only": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also trigger by keyword prefixes (e.g. `!bot`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"discord": {
|
||||||
|
"group_trigger": { "prefixes": ["!bot"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**6. Run**
|
**6. Run**
|
||||||
|
|
||||||
|
|
@ -378,6 +435,33 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>WhatsApp</b> (native via whatsmeow)</summary>
|
||||||
|
|
||||||
|
PicoClaw can connect to WhatsApp in two ways:
|
||||||
|
|
||||||
|
- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`).
|
||||||
|
- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false.
|
||||||
|
|
||||||
|
**Configure (native)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"whatsapp": {
|
||||||
|
"enabled": true,
|
||||||
|
"use_native": true,
|
||||||
|
"session_store_path": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `session_store_path` is empty, the session is stored in `<workspace>/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>QQ</b></summary>
|
<summary><b>QQ</b></summary>
|
||||||
|
|
||||||
|
|
@ -462,8 +546,6 @@ picoclaw gateway
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18791,
|
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -471,13 +553,15 @@ picoclaw gateway
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
|
||||||
|
|
||||||
**3. Set up Webhook URL**
|
**3. Set up Webhook URL**
|
||||||
|
|
||||||
LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:
|
LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Example with ngrok
|
# Example with ngrok (gateway default port is 18790)
|
||||||
ngrok http 18791
|
ngrok http 18790
|
||||||
```
|
```
|
||||||
|
|
||||||
Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.
|
Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.
|
||||||
|
|
@ -490,19 +574,18 @@ picoclaw gateway
|
||||||
|
|
||||||
> In group chats, the bot responds only when @mentioned. Replies quote the original message.
|
> In group chats, the bot responds only when @mentioned. Replies quote the original message.
|
||||||
|
|
||||||
> **Docker Compose**: Add `ports: ["18791:18791"]` to the `picoclaw-gateway` service to expose the webhook port.
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>WeCom (企业微信)</b></summary>
|
<summary><b>WeCom (企业微信)</b></summary>
|
||||||
|
|
||||||
PicoClaw supports two types of WeCom integration:
|
PicoClaw supports three types of WeCom integration:
|
||||||
|
|
||||||
**Option 1: WeCom Bot (智能机器人)** - Easier setup, supports group chats
|
**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats
|
||||||
**Option 2: WeCom App (自建应用)** - More features, proactive messaging
|
**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only
|
||||||
|
**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat
|
||||||
|
|
||||||
See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detailed setup instructions.
|
See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions.
|
||||||
|
|
||||||
**Quick Setup - WeCom Bot:**
|
**Quick Setup - WeCom Bot:**
|
||||||
|
|
||||||
|
|
@ -521,8 +604,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18793,
|
|
||||||
"webhook_path": "/webhook/wecom",
|
"webhook_path": "/webhook/wecom",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -530,6 +611,8 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> WeCom webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
|
||||||
|
|
||||||
**Quick Setup - WeCom App:**
|
**Quick Setup - WeCom App:**
|
||||||
|
|
||||||
**1. Create an app**
|
**1. Create an app**
|
||||||
|
|
@ -537,10 +620,11 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
||||||
* Go to WeCom Admin Console → App Management → Create App
|
* Go to WeCom Admin Console → App Management → Create App
|
||||||
* Copy **AgentId** and **Secret**
|
* Copy **AgentId** and **Secret**
|
||||||
* Go to "My Company" page, copy **CorpID**
|
* Go to "My Company" page, copy **CorpID**
|
||||||
|
|
||||||
**2. Configure receive message**
|
**2. Configure receive message**
|
||||||
|
|
||||||
* In App details, click "Receive Message" → "Set API"
|
* In App details, click "Receive Message" → "Set API"
|
||||||
* Set URL to `http://your-server:18792/webhook/wecom-app`
|
* Set URL to `http://your-server:18790/webhook/wecom-app`
|
||||||
* Generate **Token** and **EncodingAESKey**
|
* Generate **Token** and **EncodingAESKey**
|
||||||
|
|
||||||
**3. Configure**
|
**3. Configure**
|
||||||
|
|
@ -555,8 +639,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
||||||
"agent_id": 1000002,
|
"agent_id": 1000002,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
"webhook_path": "/webhook/wecom-app",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -570,7 +652,40 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note**: WeCom App requires opening port 18792 for webhook callbacks. Use a reverse proxy for HTTPS.
|
> **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS.
|
||||||
|
|
||||||
|
**Quick Setup - WeCom AI Bot:**
|
||||||
|
|
||||||
|
**1. Create an AI Bot**
|
||||||
|
|
||||||
|
* Go to WeCom Admin Console → App Management → AI Bot
|
||||||
|
* In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot`
|
||||||
|
* Copy **Token** and click "Random Generate" for **EncodingAESKey**
|
||||||
|
|
||||||
|
**2. Configure**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_aibot": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
"allow_from": [],
|
||||||
|
"welcome_message": "Hello! How can I help you?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -584,6 +699,31 @@ Connect Picoclaw to the Agent Social Network simply by sending a single message
|
||||||
|
|
||||||
Config file: `~/.picoclaw/config.json`
|
Config file: `~/.picoclaw/config.json`
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
You can override default paths using environment variables. This is useful for portable installations, containerized deployments, or running picoclaw as a system service. These variables are independent and control different paths.
|
||||||
|
|
||||||
|
| Variable | Description | Default Path |
|
||||||
|
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||||
|
| `PICOCLAW_CONFIG` | Overrides the path to the configuration file. This directly tells picoclaw which `config.json` to load, ignoring all other locations. | `~/.picoclaw/config.json` |
|
||||||
|
| `PICOCLAW_HOME` | Overrides the root directory for picoclaw data. This changes the default location of the `workspace` and other data directories. | `~/.picoclaw` |
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run picoclaw using a specific config file
|
||||||
|
# The workspace path will be read from within that config file
|
||||||
|
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||||
|
|
||||||
|
# Run picoclaw with all its data stored in /opt/picoclaw
|
||||||
|
# Config will be loaded from the default ~/.picoclaw/config.json
|
||||||
|
# Workspace will be created at /opt/picoclaw/workspace
|
||||||
|
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
|
|
||||||
|
# Use both for a fully customized setup
|
||||||
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
### Workspace Layout
|
### Workspace Layout
|
||||||
|
|
||||||
PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`):
|
PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`):
|
||||||
|
|
@ -603,6 +743,26 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
|
||||||
└── USER.md # User preferences
|
└── USER.md # User preferences
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Skill Sources
|
||||||
|
|
||||||
|
By default, skills are loaded from:
|
||||||
|
|
||||||
|
1. `~/.picoclaw/workspace/skills` (workspace)
|
||||||
|
2. `~/.picoclaw/skills` (global)
|
||||||
|
3. `<current-working-directory>/skills` (builtin)
|
||||||
|
|
||||||
|
For advanced/test setups, you can override the builtin skills root with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unified Command Execution Policy
|
||||||
|
|
||||||
|
- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.
|
||||||
|
- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup.
|
||||||
|
- Unknown slash command (for example `/foo`) passes through to normal LLM processing.
|
||||||
|
- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing.
|
||||||
### 🔒 Security Sandbox
|
### 🔒 Security Sandbox
|
||||||
|
|
||||||
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
||||||
|
|
@ -779,7 +939,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
||||||
### Providers
|
### Providers
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level.
|
||||||
|
|
||||||
| Provider | Purpose | Get API Key |
|
| Provider | Purpose | Get API Key |
|
||||||
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
|
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
|
||||||
|
|
@ -807,7 +967,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
#### 📋 All Supported Vendors
|
#### 📋 All Supported Vendors
|
||||||
|
|
||||||
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
||||||
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
|
| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
|
||||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
|
|
@ -819,6 +979,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
||||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
||||||
|
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1 | OpenAI | Your LiteLLM proxy key |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
||||||
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
|
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
|
||||||
|
|
@ -915,10 +1076,24 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**LiteLLM Proxy**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "lite-gpt4",
|
||||||
|
"model": "litellm/lite-gpt4",
|
||||||
|
"api_base": "http://localhost:4000/v1",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
PicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.
|
||||||
|
|
||||||
#### Load Balancing
|
#### Load Balancing
|
||||||
|
|
||||||
Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them:
|
Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them:
|
||||||
|
|
@ -1043,6 +1218,10 @@ picoclaw agent -m "Hello"
|
||||||
"model": "anthropic/claude-opus-4-5"
|
"model": "anthropic/claude-opus-4-5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"session": {
|
||||||
|
"dm_scope": "per-channel-peer",
|
||||||
|
"backlog_limit": 20
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"openrouter": {
|
"openrouter": {
|
||||||
"api_key": "sk-or-v1-xxx"
|
"api_key": "sk-or-v1-xxx"
|
||||||
|
|
@ -1063,7 +1242,11 @@ picoclaw agent -m "Hello"
|
||||||
"allow_from": [""]
|
"allow_from": [""]
|
||||||
},
|
},
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
"enabled": false
|
"enabled": false,
|
||||||
|
"bridge_url": "ws://localhost:3001",
|
||||||
|
"use_native": false,
|
||||||
|
"session_store_path": "",
|
||||||
|
"allow_from": []
|
||||||
},
|
},
|
||||||
"feishu": {
|
"feishu": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
@ -1090,6 +1273,16 @@ picoclaw agent -m "Hello"
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://localhost:8888",
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
|
|
@ -1143,14 +1336,73 @@ discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
### Web search says "API 配置问题"
|
### Web search says "API key configuration issue"
|
||||||
|
|
||||||
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
|
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
|
||||||
|
|
||||||
To enable web search:
|
#### Search Provider Priority
|
||||||
|
|
||||||
1. **Option 1 (Recommended)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) for the best results.
|
PicoClaw automatically selects the best available search provider in this order:
|
||||||
2. **Option 2 (No Credit Card)**: If you don't have a key, we automatically fall back to **DuckDuckGo** (no key required).
|
1. **Perplexity** (if enabled and API key configured) - AI-powered search with citations
|
||||||
|
2. **Brave Search** (if enabled and API key configured) - Privacy-focused paid API ($5/1000 queries)
|
||||||
|
3. **SearXNG** (if enabled and base_url configured) - Self-hosted metasearch aggregating 70+ engines (free)
|
||||||
|
4. **DuckDuckGo** (if enabled, default fallback) - No API key required (free)
|
||||||
|
|
||||||
|
#### Web Search Configuration Options
|
||||||
|
|
||||||
|
**Option 1 (Best Results)**: Perplexity AI Search
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "YOUR_PERPLEXITY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2 (Paid API)**: Get an API key at [https://brave.com/search/api](https://brave.com/search/api) ($5/1000 queries, ~$5-6/month)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"brave": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 3 (Self-Hosted)**: Deploy your own [SearXNG](https://github.com/searxng/searxng) instance
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"searxng": {
|
||||||
|
"enabled": true,
|
||||||
|
"base_url": "http://your-server:8888",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Benefits of SearXNG:
|
||||||
|
- **Zero cost**: No API fees or rate limits
|
||||||
|
- **Privacy-focused**: Self-hosted, no tracking
|
||||||
|
- **Aggregate results**: Queries 70+ search engines simultaneously
|
||||||
|
- **Perfect for cloud VMs**: Solves datacenter IP blocking issues (Oracle Cloud, GCP, AWS, Azure)
|
||||||
|
- **No API key needed**: Just deploy and configure the base URL
|
||||||
|
|
||||||
|
**Option 4 (No Setup Required)**: DuckDuckGo is enabled by default as fallback (no API key needed)
|
||||||
|
|
||||||
Add the key to `~/.picoclaw/config.json` if using Brave:
|
Add the key to `~/.picoclaw/config.json` if using Brave:
|
||||||
|
|
||||||
|
|
@ -1166,6 +1418,16 @@ Add the key to `~/.picoclaw/config.json` if using Brave:
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_PERPLEXITY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://your-searxng-instance:8888",
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1184,10 +1446,11 @@ This happens when another instance of the bot is running. Make sure only one `pi
|
||||||
|
|
||||||
## 📝 API Key Comparison
|
## 📝 API Key Comparison
|
||||||
|
|
||||||
| Service | Free Tier | Use Case |
|
| Service | Free Tier | Use Case |
|
||||||
| ---------------- | ------------------- | ------------------------------------- |
|
| ---------------- | ------------------------ | ------------------------------------- |
|
||||||
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
||||||
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
||||||
| **Brave Search** | 2000 queries/month | Web search functionality |
|
| **Brave Search** | Paid ($5/1000 queries) | Web search functionality |
|
||||||
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
|
||||||
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
||||||
|
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
||||||
|
|
|
||||||
142
README.pt-br.md
142
README.pt-br.md
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<br>
|
||||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
|
|
||||||
⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz.
|
⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz.
|
||||||
|
|
||||||
🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM e x86. Um clique e já era!
|
🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM, MIPS e x86. Um clique e já era!
|
||||||
|
|
||||||
🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop.
|
🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop.
|
||||||
|
|
||||||
|
|
@ -165,39 +165,43 @@ Você tambêm pode rodar o PicoClaw usando Docker Compose sem instalar nada loca
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Configure suas API keys
|
# 2. Primeiro uso — gera docker/data/config.json automaticamente e para
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc.
|
# O contêiner exibe "First-run setup complete." e para.
|
||||||
|
|
||||||
# 3. Build & Iniciar
|
# 3. Configure suas API keys
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # Chaves de API do provedor, tokens de bot, etc.
|
||||||
|
|
||||||
|
# 4. Iniciar
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`.
|
> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Ver logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
# 4. Ver logs
|
# 6. Parar
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
|
|
||||||
# 5. Parar
|
|
||||||
docker compose --profile gateway down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Modo Agente (Execução única)
|
### Modo Agente (Execução única)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Fazer uma pergunta
|
# Fazer uma pergunta
|
||||||
docker compose run --rm picoclaw-agent -m "Quanto e 2+2?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Quanto e 2+2?"
|
||||||
|
|
||||||
# Modo interativo
|
# Modo interativo
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rebuild
|
### Atualizar
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Início Rápido
|
### 🚀 Início Rápido
|
||||||
|
|
@ -222,6 +226,7 @@ picoclaw onboard
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -246,6 +251,9 @@ picoclaw onboard
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Novo**: O formato de configuração `model_list` permite adicionar provedores sem alterar código. Veja [Configuração de Modelo](#configuração-de-modelo-model_list) para detalhes.
|
||||||
|
> `request_timeout` é opcional e usa segundos. Se omitido ou definido como `<= 0`, o PicoClaw usa o timeout padrão (120s).
|
||||||
|
|
||||||
**3. Obter API Keys**
|
**3. Obter API Keys**
|
||||||
|
|
||||||
* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
|
|
@ -274,7 +282,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom.
|
||||||
| **QQ** | Fácil (AppID + AppSecret) |
|
| **QQ** | Fácil (AppID + AppSecret) |
|
||||||
| **DingTalk** | Médio (credenciais do app) |
|
| **DingTalk** | Médio (credenciais do app) |
|
||||||
| **LINE** | Médio (credenciais + webhook URL) |
|
| **LINE** | Médio (credenciais + webhook URL) |
|
||||||
| **WeCom** | Médio (CorpID + configuração webhook) |
|
| **WeCom AI Bot** | Médio (Token + chave AES) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recomendado)</summary>
|
<summary><b>Telegram</b> (Recomendado)</summary>
|
||||||
|
|
@ -442,8 +450,6 @@ picoclaw gateway
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18791,
|
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -457,11 +463,13 @@ O LINE requer HTTPS para webhooks. Use um reverse proxy ou tunnel:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Exemplo com ngrok
|
# Exemplo com ngrok
|
||||||
ngrok http 18791
|
ngrok http 18790
|
||||||
```
|
```
|
||||||
|
|
||||||
Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**.
|
Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**.
|
||||||
|
|
||||||
|
> **Nota**: O webhook do LINE é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Use um proxy reverso/HTTPS ou túnel (como ngrok) para expor o Gateway de forma segura quando necessário.
|
||||||
|
|
||||||
**4. Executar**
|
**4. Executar**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -470,19 +478,20 @@ picoclaw gateway
|
||||||
|
|
||||||
> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original.
|
> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original.
|
||||||
|
|
||||||
> **Docker Compose**: Adicione `ports: ["18791:18791"]` ao serviço `picoclaw-gateway` para expor a porta do webhook.
|
> **Docker Compose**: Se você usa Docker Compose, exponha o Gateway (padrão 127.0.0.1:18790) se precisar acessar o webhook LINE externamente, por exemplo `ports: ["18790:18790"]`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>WeCom (WeChat Work)</b></summary>
|
<summary><b>WeCom (WeChat Work)</b></summary>
|
||||||
|
|
||||||
O PicoClaw suporta dois tipos de integração WeCom:
|
O PicoClaw suporta três tipos de integração WeCom:
|
||||||
|
|
||||||
**Opção 1: WeCom Bot (Robô Inteligente)** - Configuração mais fácil, suporta chats em grupo
|
**Opção 1: WeCom Bot (Robô)** - Configuração mais fácil, suporta chats em grupo
|
||||||
**Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas
|
**Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas, somente chat privado
|
||||||
|
**Opção 3: WeCom AI Bot (Robô Inteligente)** - Bot IA oficial, respostas em streaming, suporta grupo e privado
|
||||||
|
|
||||||
Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para instruções detalhadas.
|
Veja o [Guia de Configuração WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) para instruções detalhadas.
|
||||||
|
|
||||||
**Configuração Rápida - WeCom Bot:**
|
**Configuração Rápida - WeCom Bot:**
|
||||||
|
|
||||||
|
|
@ -501,8 +510,6 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18793,
|
|
||||||
"webhook_path": "/webhook/wecom",
|
"webhook_path": "/webhook/wecom",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -510,6 +517,8 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Nota**: O webhook do WeCom Bot é atendido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Use um proxy reverso/HTTPS ou túnel para expor o Gateway em produção.
|
||||||
|
|
||||||
**Configuração Rápida - WeCom App:**
|
**Configuração Rápida - WeCom App:**
|
||||||
|
|
||||||
**1. Criar um aplicativo**
|
**1. Criar um aplicativo**
|
||||||
|
|
@ -521,7 +530,7 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
|
||||||
**2. Configurar recebimento de mensagens**
|
**2. Configurar recebimento de mensagens**
|
||||||
|
|
||||||
* Nos detalhes do aplicativo, clique em "Receber Mensagens" → "Configurar API"
|
* Nos detalhes do aplicativo, clique em "Receber Mensagens" → "Configurar API"
|
||||||
* Defina a URL como `http://your-server:18792/webhook/wecom-app`
|
* Defina a URL como `http://your-server:18790/webhook/wecom-app`
|
||||||
* Gere o **Token** e o **EncodingAESKey**
|
* Gere o **Token** e o **EncodingAESKey**
|
||||||
|
|
||||||
**3. Configurar**
|
**3. Configurar**
|
||||||
|
|
@ -536,8 +545,6 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
|
||||||
"agent_id": 1000002,
|
"agent_id": 1000002,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
"webhook_path": "/webhook/wecom-app",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -551,7 +558,40 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Nota**: O WeCom App requer a abertura da porta 18792 para callbacks de webhook. Use um proxy reverso para HTTPS em produção.
|
> **Nota**: O WeCom App (callbacks de webhook) é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Em produção use um proxy reverso HTTPS para expor a porta do Gateway, ou atualize `PICOCLAW_GATEWAY_HOST` para `0.0.0.0` se necessário.
|
||||||
|
|
||||||
|
**Configuração Rápida - WeCom AI Bot:**
|
||||||
|
|
||||||
|
**1. Criar um AI Bot**
|
||||||
|
|
||||||
|
* Acesse o Console de Administração WeCom → Gerenciamento de Aplicativos → AI Bot
|
||||||
|
* Configure a URL de callback: `http://your-server:18791/webhook/wecom-aibot`
|
||||||
|
* Copie o **Token** e gere o **EncodingAESKey**
|
||||||
|
|
||||||
|
**2. Configurar**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_aibot": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
"allow_from": [],
|
||||||
|
"welcome_message": "Olá! Como posso ajudá-lo?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Executar**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Nota**: O WeCom AI Bot usa protocolo de pull em streaming — sem preocupações com timeout de resposta. Tarefas longas (>5,5 min) alternam automaticamente para entrega via `response_url`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -565,6 +605,31 @@ Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma única men
|
||||||
|
|
||||||
Arquivo de configuração: `~/.picoclaw/config.json`
|
Arquivo de configuração: `~/.picoclaw/config.json`
|
||||||
|
|
||||||
|
### Variáveis de Ambiente
|
||||||
|
|
||||||
|
Você pode substituir os caminhos padrão usando variáveis de ambiente. Isso é útil para instalações portáteis, implantações em contêineres ou para executar o picoclaw como um serviço do sistema. Essas variáveis são independentes e controlam caminhos diferentes.
|
||||||
|
|
||||||
|
| Variável | Descrição | Caminho Padrão |
|
||||||
|
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||||
|
| `PICOCLAW_CONFIG` | Substitui o caminho para o arquivo de configuração. Isso informa diretamente ao picoclaw qual `config.json` carregar, ignorando todos os outros locais. | `~/.picoclaw/config.json` |
|
||||||
|
| `PICOCLAW_HOME` | Substitui o diretório raiz dos dados do picoclaw. Isso altera o local padrão do `workspace` e de outros diretórios de dados. | `~/.picoclaw` |
|
||||||
|
|
||||||
|
**Exemplos:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Executar o picoclaw usando um arquivo de configuração específico
|
||||||
|
# O caminho do workspace será lido de dentro desse arquivo de configuração
|
||||||
|
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||||
|
|
||||||
|
# Executar o picoclaw com todos os seus dados armazenados em /opt/picoclaw
|
||||||
|
# A configuração será carregada do ~/.picoclaw/config.json padrão
|
||||||
|
# O workspace será criado em /opt/picoclaw/workspace
|
||||||
|
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
|
|
||||||
|
# Use ambos para uma configuração totalmente personalizada
|
||||||
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
### Estrutura do Workspace
|
### Estrutura do Workspace
|
||||||
|
|
||||||
O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/workspace`):
|
O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/workspace`):
|
||||||
|
|
@ -758,7 +823,7 @@ O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se com
|
||||||
### Provedores
|
### Provedores
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serão automaticamente transcritas.
|
> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de áudio de qualquer canal serão automaticamente transcritas no nível do agente.
|
||||||
|
|
||||||
| Provedor | Finalidade | Obter API Key |
|
| Provedor | Finalidade | Obter API Key |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
@ -973,6 +1038,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
||||||
```
|
```
|
||||||
> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
|
> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
|
||||||
|
|
||||||
|
**Proxy/API personalizada**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Balanceamento de Carga
|
#### Balanceamento de Carga
|
||||||
|
|
||||||
Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
|
Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
|
||||||
|
|
|
||||||
142
README.vi.md
142
README.vi.md
|
|
@ -3,11 +3,11 @@
|
||||||
|
|
||||||
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
|
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
|
||||||
|
|
||||||
<h3>Phần cứng $10 · RAM 10MB · Khởi động 1 giây · 皮皮虾,我们走!</h3>
|
<h3>Phần cứng $10 · RAM 10MB · Khởi động 1 giây · Nào, xuất phát!</h3>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<br>
|
||||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
|
|
||||||
⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz.
|
⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz.
|
||||||
|
|
||||||
🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM và x86. Một click là chạy!
|
🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM, MIPS và x86. Một click là chạy!
|
||||||
|
|
||||||
🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.
|
🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.
|
||||||
|
|
||||||
|
|
@ -145,39 +145,43 @@ Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cà
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Thiết lập API Key
|
# 2. Lần chạy đầu tiên — tự tạo docker/data/config.json rồi dừng lại
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v.
|
# Container hiển thị "First-run setup complete." rồi tự dừng.
|
||||||
|
|
||||||
# 3. Build & Khởi động
|
# 3. Thiết lập API Key
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # API key của provider, bot token, v.v.
|
||||||
|
|
||||||
|
# 4. Khởi động
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`.
|
> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Xem logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
# 4. Xem logs
|
# 6. Dừng
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
|
|
||||||
# 5. Dừng
|
|
||||||
docker compose --profile gateway down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Chế độ Agent (chạy một lần)
|
### Chế độ Agent (chạy một lần)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Đặt câu hỏi
|
# Đặt câu hỏi
|
||||||
docker compose run --rm picoclaw-agent -m "2+2 bằng mấy?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 bằng mấy?"
|
||||||
|
|
||||||
# Chế độ tương tác
|
# Chế độ tương tác
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build lại
|
### Cập nhật
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Bắt đầu nhanh
|
### 🚀 Bắt đầu nhanh
|
||||||
|
|
@ -202,6 +206,7 @@ picoclaw onboard
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -220,6 +225,9 @@ picoclaw onboard
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Mới**: Định dạng cấu hình `model_list` cho phép thêm nhà cung cấp mà không cần thay đổi mã nguồn. Xem [Cấu hình Mô hình](#cấu-hình-mô-hình-model_list) để biết chi tiết.
|
||||||
|
> `request_timeout` là tùy chọn và dùng đơn vị giây. Nếu bỏ qua hoặc đặt `<= 0`, PicoClaw sẽ dùng timeout mặc định (120s).
|
||||||
|
|
||||||
**3. Lấy API Key**
|
**3. Lấy API Key**
|
||||||
|
|
||||||
* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
|
|
@ -248,7 +256,7 @@ Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk, LINE hoặc WeCom.
|
||||||
| **QQ** | Dễ (AppID + AppSecret) |
|
| **QQ** | Dễ (AppID + AppSecret) |
|
||||||
| **DingTalk** | Trung bình (app credentials) |
|
| **DingTalk** | Trung bình (app credentials) |
|
||||||
| **LINE** | Trung bình (credentials + webhook URL) |
|
| **LINE** | Trung bình (credentials + webhook URL) |
|
||||||
| **WeCom** | Trung bình (CorpID + cấu hình webhook) |
|
| **WeCom AI Bot** | Trung bình (Token + khóa AES) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Khuyên dùng)</summary>
|
<summary><b>Telegram</b> (Khuyên dùng)</summary>
|
||||||
|
|
@ -416,8 +424,6 @@ picoclaw gateway
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18791,
|
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -431,7 +437,7 @@ LINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ví dụ với ngrok
|
# Ví dụ với ngrok
|
||||||
ngrok http 18791
|
ngrok http 18790
|
||||||
```
|
```
|
||||||
|
|
||||||
Sau đó cài đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**.
|
Sau đó cài đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**.
|
||||||
|
|
@ -444,19 +450,20 @@ picoclaw gateway
|
||||||
|
|
||||||
> Trong nhóm chat, bot chỉ phản hồi khi được @mention. Các câu trả lời sẽ trích dẫn tin nhắn gốc.
|
> Trong nhóm chat, bot chỉ phản hồi khi được @mention. Các câu trả lời sẽ trích dẫn tin nhắn gốc.
|
||||||
|
|
||||||
> **Docker Compose**: Thêm `ports: ["18791:18791"]` vào service `picoclaw-gateway` để mở port webhook.
|
> **Docker Compose**: Nếu bạn cần mở port webhook cục bộ, hãy thêm một rule chuyển tiếp từ port Gateway (mặc định 18790) tới host. Lưu ý: LINE webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790).
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>WeCom (WeChat Work)</b></summary>
|
<summary><b>WeCom (WeChat Work)</b></summary>
|
||||||
|
|
||||||
PicoClaw hỗ trợ hai loại tích hợp WeCom:
|
PicoClaw hỗ trợ ba loại tích hợp WeCom:
|
||||||
|
|
||||||
**Tùy chọn 1: WeCom Bot (Robot Thông minh)** - Thiết lập dễ dàng hơn, hỗ trợ chat nhóm
|
**Tùy chọn 1: WeCom Bot (Robot)** - Thiết lập dễ dàng hơn, hỗ trợ chat nhóm
|
||||||
**Tùy chọn 2: WeCom App (Ứng dụng Tự xây dựng)** - Nhiều tính năng hơn, nhắn tin chủ động
|
**Tùy chọn 2: WeCom App (Ứng dụng Tùy chỉnh)** - Nhiều tính năng hơn, nhắn tin chủ động, chỉ chat riêng tư
|
||||||
|
**Tùy chọn 3: WeCom AI Bot (Bot Thông Minh)** - Bot AI chính thức, phản hồi streaming, hỗ trợ nhóm và riêng tư
|
||||||
|
|
||||||
Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) để biết hướng dẫn chi tiết.
|
Xem [Hướng dẫn Cấu hình WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) để biết hướng dẫn chi tiết.
|
||||||
|
|
||||||
**Thiết lập Nhanh - WeCom Bot:**
|
**Thiết lập Nhanh - WeCom Bot:**
|
||||||
|
|
||||||
|
|
@ -475,8 +482,6 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18793,
|
|
||||||
"webhook_path": "/webhook/wecom",
|
"webhook_path": "/webhook/wecom",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -484,6 +489,8 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Lưu ý:** Các endpoint webhook của WeCom Bot được phục vụ bởi máy chủ Gateway HTTP dùng chung (mặc định 127.0.0.1:18790). Nếu bạn cần truy cập từ bên ngoài, hãy cấu hình reverse proxy hoặc mở cổng Gateway tương ứng.
|
||||||
|
|
||||||
**Thiết lập Nhanh - WeCom App:**
|
**Thiết lập Nhanh - WeCom App:**
|
||||||
|
|
||||||
**1. Tạo ứng dụng**
|
**1. Tạo ứng dụng**
|
||||||
|
|
@ -495,7 +502,7 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
|
||||||
**2. Cấu hình nhận tin nhắn**
|
**2. Cấu hình nhận tin nhắn**
|
||||||
|
|
||||||
* Trong chi tiết ứng dụng, nhấp vào "Nhận Tin nhắn" → "Thiết lập API"
|
* Trong chi tiết ứng dụng, nhấp vào "Nhận Tin nhắn" → "Thiết lập API"
|
||||||
* Đặt URL thành `http://your-server:18792/webhook/wecom-app`
|
* Đặt URL thành `http://your-server:18790/webhook/wecom-app`
|
||||||
* Tạo **Token** và **EncodingAESKey**
|
* Tạo **Token** và **EncodingAESKey**
|
||||||
|
|
||||||
**3. Cấu hình**
|
**3. Cấu hình**
|
||||||
|
|
@ -510,8 +517,6 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
|
||||||
"agent_id": 1000002,
|
"agent_id": 1000002,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
"webhook_path": "/webhook/wecom-app",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -525,7 +530,40 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Lưu ý**: WeCom App yêu cầu mở cổng 18792 cho callback webhook. Sử dụng proxy ngược cho HTTPS trong môi trường sản xuất.
|
> **Lưu ý**: WeCom App callback webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790). Sử dụng proxy ngược để cung cấp HTTPS trong môi trường production nếu cần.
|
||||||
|
|
||||||
|
**Thiết lập Nhanh - WeCom AI Bot:**
|
||||||
|
|
||||||
|
**1. Tạo AI Bot**
|
||||||
|
|
||||||
|
* Truy cập Bảng điều khiển Quản trị WeCom → Quản lý Ứng dụng → AI Bot
|
||||||
|
* Cấu hình URL callback: `http://your-server:18791/webhook/wecom-aibot`
|
||||||
|
* Sao chép **Token** và tạo **EncodingAESKey**
|
||||||
|
|
||||||
|
**2. Cấu hình**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_aibot": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
"allow_from": [],
|
||||||
|
"welcome_message": "Xin chào! Tôi có thể giúp gì cho bạn?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Chạy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Lưu ý**: WeCom AI Bot sử dụng giao thức pull streaming — không lo timeout phản hồi. Tác vụ dài (>5,5 phút) tự động chuyển sang gửi qua `response_url`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -539,6 +577,31 @@ Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một
|
||||||
|
|
||||||
File cấu hình: `~/.picoclaw/config.json`
|
File cấu hình: `~/.picoclaw/config.json`
|
||||||
|
|
||||||
|
### Biến môi trường
|
||||||
|
|
||||||
|
Bạn có thể ghi đè các đường dẫn mặc định bằng cách sử dụng các biến môi trường. Điều này hữu ích cho việc cài đặt di động, triển khai container hóa hoặc chạy picoclaw như một dịch vụ hệ thống. Các biến này độc lập và kiểm soát các đường dẫn khác nhau.
|
||||||
|
|
||||||
|
| Biến | Mô tả | Đường dẫn mặc định |
|
||||||
|
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||||
|
| `PICOCLAW_CONFIG` | Ghi đè đường dẫn đến file cấu hình. Điều này trực tiếp yêu cầu picoclaw tải file `config.json` nào, bỏ qua tất cả các vị trí khác. | `~/.picoclaw/config.json` |
|
||||||
|
| `PICOCLAW_HOME` | Ghi đè thư mục gốc cho dữ liệu picoclaw. Điều này thay đổi vị trí mặc định của `workspace` và các thư mục dữ liệu khác. | `~/.picoclaw` |
|
||||||
|
|
||||||
|
**Ví dụ:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Chạy picoclaw bằng một file cấu hình cụ thể
|
||||||
|
# Đường dẫn workspace sẽ được đọc từ trong file cấu hình đó
|
||||||
|
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||||
|
|
||||||
|
# Chạy picoclaw với tất cả dữ liệu được lưu trữ trong /opt/picoclaw
|
||||||
|
# Cấu hình sẽ được tải từ ~/.picoclaw/config.json mặc định
|
||||||
|
# Workspace sẽ được tạo tại /opt/picoclaw/workspace
|
||||||
|
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
|
|
||||||
|
# Sử dụng cả hai để có thiết lập tùy chỉnh hoàn toàn
|
||||||
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
### Cấu trúc Workspace
|
### Cấu trúc Workspace
|
||||||
|
|
||||||
PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`):
|
PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`):
|
||||||
|
|
@ -732,7 +795,7 @@ Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và
|
||||||
### Nhà cung cấp (Providers)
|
### Nhà cung cấp (Providers)
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn thoại trên Telegram sẽ được tự động chuyển thành văn bản.
|
> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn âm thanh từ bất kỳ kênh nào sẽ được tự động chuyển thành văn bản ở cấp độ agent.
|
||||||
|
|
||||||
| Nhà cung cấp | Mục đích | Lấy API Key |
|
| Nhà cung cấp | Mục đích | Lấy API Key |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
@ -944,6 +1007,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
```
|
```
|
||||||
> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
|
> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
|
||||||
|
|
||||||
|
**Proxy/API tùy chỉnh**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Cân bằng Tải tải
|
#### Cân bằng Tải tải
|
||||||
|
|
||||||
Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
|
Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
|
||||||
|
|
|
||||||
111
README.zh.md
111
README.zh.md
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<br>
|
||||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
|
|
||||||
⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。
|
⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。
|
||||||
|
|
||||||
🌍 **真正可移植**: 跨 RISC-V、ARM 和 x86 架构的单二进制文件,一键运行!
|
🌍 **真正可移植**: 跨 RISC-V、ARM、MIPS 和 x86 架构的单二进制文件,一键运行!
|
||||||
|
|
||||||
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
||||||
|
|
||||||
|
|
@ -166,41 +166,43 @@ make install
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. 设置 API Key
|
# 2. 首次运行 — 自动生成 docker/data/config.json 后退出
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等
|
# 容器打印 "First-run setup complete." 后自动停止
|
||||||
|
|
||||||
# 3. 构建并启动
|
# 3. 填写 API Key 等配置
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # 设置 provider API key、Bot Token 等
|
||||||
|
|
||||||
|
# 4. 正式启动
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
**Docker 用户**: 默认情况下, Gateway监听 `127.0.0.1`,这使得这个端口未暴露到容器外。如果你需要通过端口映射访问健康检查接口, 请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。
|
> **Docker 用户**: 默认情况下, Gateway 监听 `127.0.0.1`,该端口不会暴露到容器外。如果需要通过端口映射访问健康检查接口,请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。
|
||||||
|
|
||||||
# 4. 查看日志
|
```bash
|
||||||
docker compose logs -f picoclaw-gateway
|
# 5. 查看日志
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
# 5. 停止
|
|
||||||
docker compose --profile gateway down
|
|
||||||
|
|
||||||
|
# 6. 停止
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent 模式 (一次性运行)
|
### Agent 模式 (一次性运行)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 提问
|
# 提问
|
||||||
docker compose run --rm picoclaw-agent -m "2+2 等于几?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 等于几?"
|
||||||
|
|
||||||
# 交互模式
|
# 交互模式
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 重新构建
|
### 更新镜像
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 快速开始
|
### 🚀 快速开始
|
||||||
|
|
@ -234,7 +236,8 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "your-api-key"
|
"api_key": "your-api-key",
|
||||||
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
|
@ -263,6 +266,7 @@ picoclaw onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
|
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
|
||||||
|
> `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。
|
||||||
|
|
||||||
**3. 获取 API Key**
|
**3. 获取 API Key**
|
||||||
|
|
||||||
|
|
@ -286,6 +290,8 @@ picoclaw agent -m "2+2 等于几?"
|
||||||
|
|
||||||
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
|
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
|
||||||
|
|
||||||
|
> **注意**: 所有 Webhook 类渠道(LINE、WeCom 等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。注意:飞书(Feishu)使用 WebSocket/SDK 模式,不通过该共享 HTTP webhook 服务器接收消息。
|
||||||
|
|
||||||
### 核心渠道
|
### 核心渠道
|
||||||
|
|
||||||
| 渠道 | 设置难度 | 特性说明 | 文档链接 |
|
| 渠道 | 设置难度 | 特性说明 | 文档链接 |
|
||||||
|
|
@ -295,12 +301,19 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
||||||
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
|
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
|
||||||
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
|
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
|
||||||
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
|
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
|
||||||
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)和自建应用(API) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) |
|
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](docs/channels/wecom/wecom_aibot/README.zh.md) |
|
||||||
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) |
|
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) |
|
||||||
| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
|
| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
|
||||||
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
||||||
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
||||||
|
|
||||||
|
### Telegram 命令注册(启动时自动同步)
|
||||||
|
|
||||||
|
PicoClaw 现在使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
|
||||||
|
Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。
|
||||||
|
|
||||||
|
如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
|
||||||
|
|
||||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||||
|
|
||||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||||
|
|
@ -311,6 +324,31 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
||||||
|
|
||||||
配置文件路径: `~/.picoclaw/config.json`
|
配置文件路径: `~/.picoclaw/config.json`
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
你可以使用环境变量覆盖默认路径。这对于便携安装、容器化部署或将 picoclaw 作为系统服务运行非常有用。这些变量是独立的,控制不同的路径。
|
||||||
|
|
||||||
|
| 变量 | 描述 | 默认路径 |
|
||||||
|
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||||
|
| `PICOCLAW_CONFIG` | 覆盖配置文件的路径。这直接告诉 picoclaw 加载哪个 `config.json`,忽略所有其他位置。 | `~/.picoclaw/config.json` |
|
||||||
|
| `PICOCLAW_HOME` | 覆盖 picoclaw 数据根目录。这会更改 `workspace` 和其他数据目录的默认位置。 | `~/.picoclaw` |
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用特定的配置文件运行 picoclaw
|
||||||
|
# 工作区路径将从该配置文件中读取
|
||||||
|
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||||
|
|
||||||
|
# 在 /opt/picoclaw 中存储所有数据运行 picoclaw
|
||||||
|
# 配置将从默认的 ~/.picoclaw/config.json 加载
|
||||||
|
# 工作区将在 /opt/picoclaw/workspace 创建
|
||||||
|
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
|
|
||||||
|
# 同时使用两者进行完全自定义设置
|
||||||
|
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
### 工作区布局 (Workspace Layout)
|
### 工作区布局 (Workspace Layout)
|
||||||
|
|
||||||
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`):
|
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`):
|
||||||
|
|
@ -331,6 +369,26 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 技能来源 (Skill Sources)
|
||||||
|
|
||||||
|
默认情况下,技能会按以下顺序加载:
|
||||||
|
|
||||||
|
1. `~/.picoclaw/workspace/skills`(工作区)
|
||||||
|
2. `~/.picoclaw/skills`(全局)
|
||||||
|
3. `<current-working-directory>/skills`(内置)
|
||||||
|
|
||||||
|
在高级/测试场景下,可通过以下环境变量覆盖内置技能目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
|
```
|
||||||
|
|
||||||
|
### 统一命令执行策略
|
||||||
|
|
||||||
|
- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。
|
||||||
|
- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。
|
||||||
|
- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。
|
||||||
|
- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。
|
||||||
### 心跳 / 周期性任务 (Heartbeat)
|
### 心跳 / 周期性任务 (Heartbeat)
|
||||||
|
|
||||||
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
|
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
|
||||||
|
|
@ -414,7 +472,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
### 提供商 (Providers)
|
### 提供商 (Providers)
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
|
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,任意渠道的音频消息都将在 Agent 层面自动转录为文字。
|
||||||
|
|
||||||
| 提供商 | 用途 | 获取 API Key |
|
| 提供商 | 用途 | 获取 API Key |
|
||||||
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
||||||
|
|
@ -550,7 +608,8 @@ Agent 读取 HEARTBEAT.md
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -669,6 +728,10 @@ picoclaw agent -m "你好"
|
||||||
"model": "anthropic/claude-opus-4-5"
|
"model": "anthropic/claude-opus-4-5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"session": {
|
||||||
|
"dm_scope": "per-channel-peer",
|
||||||
|
"backlog_limit": 20
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"openrouter": {
|
"openrouter": {
|
||||||
"api_key": "sk-or-v1-xxx"
|
"api_key": "sk-or-v1-xxx"
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 96 KiB |
49
cmd/picoclaw-launcher-tui/internal/config/store.go
Normal file
49
cmd/picoclaw-launcher-tui/internal/config/store.go
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
package configstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
configDirName = ".picoclaw"
|
||||||
|
configFileName = "config.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ConfigPath() (string, error) {
|
||||||
|
dir, err := ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, configFileName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigDir() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, configDirName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*picoclawconfig.Config, error) {
|
||||||
|
path, err := ConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return picoclawconfig.LoadConfig(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Save(cfg *picoclawconfig.Config) error {
|
||||||
|
if cfg == nil {
|
||||||
|
return errors.New("config is nil")
|
||||||
|
}
|
||||||
|
path, err := ConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return picoclawconfig.SaveConfig(path, cfg)
|
||||||
|
}
|
||||||
506
cmd/picoclaw-launcher-tui/internal/ui/app.go
Normal file
506
cmd/picoclaw-launcher-tui/internal/ui/app.go
Normal file
|
|
@ -0,0 +1,506 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config"
|
||||||
|
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type appState struct {
|
||||||
|
app *tview.Application
|
||||||
|
pages *tview.Pages
|
||||||
|
stack []string
|
||||||
|
config *picoclawconfig.Config
|
||||||
|
configPath string
|
||||||
|
gatewayCmd *exec.Cmd
|
||||||
|
menus map[string]*Menu
|
||||||
|
original []byte
|
||||||
|
hasOriginal bool
|
||||||
|
backupPath string
|
||||||
|
dirty bool
|
||||||
|
logPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run() error {
|
||||||
|
applyStyles()
|
||||||
|
cfg, err := configstore.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path, err := configstore.ConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = picoclawconfig.DefaultConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
originalData, hasOriginal := loadOriginalConfig(path)
|
||||||
|
backupPath := path + ".bak"
|
||||||
|
if hasOriginal {
|
||||||
|
_ = writeBackupConfig(backupPath, originalData)
|
||||||
|
}
|
||||||
|
|
||||||
|
logPath := filepath.Join(filepath.Dir(path), "gateway.log")
|
||||||
|
state := &appState{
|
||||||
|
app: tview.NewApplication(),
|
||||||
|
pages: tview.NewPages(),
|
||||||
|
config: cfg,
|
||||||
|
configPath: path,
|
||||||
|
menus: map[string]*Menu{},
|
||||||
|
original: originalData,
|
||||||
|
hasOriginal: hasOriginal,
|
||||||
|
backupPath: backupPath,
|
||||||
|
logPath: logPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
state.push("main", state.mainMenu())
|
||||||
|
|
||||||
|
root := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||||
|
root.AddItem(bannerView(), 6, 0, false)
|
||||||
|
root.AddItem(state.pages, 0, 1, true)
|
||||||
|
|
||||||
|
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) push(name string, primitive tview.Primitive) {
|
||||||
|
s.pages.AddPage(name, primitive, true, true)
|
||||||
|
s.stack = append(s.stack, name)
|
||||||
|
s.pages.SwitchToPage(name)
|
||||||
|
if menu, ok := primitive.(*Menu); ok {
|
||||||
|
s.menus[name] = menu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) pop() {
|
||||||
|
if len(s.stack) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
last := s.stack[len(s.stack)-1]
|
||||||
|
s.pages.RemovePage(last)
|
||||||
|
s.stack = s.stack[:len(s.stack)-1]
|
||||||
|
if len(s.stack) == 0 {
|
||||||
|
s.app.Stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current := s.stack[len(s.stack)-1]
|
||||||
|
s.pages.SwitchToPage(current)
|
||||||
|
if menu, ok := s.menus[current]; ok {
|
||||||
|
s.refreshMenu(current, menu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) mainMenu() tview.Primitive {
|
||||||
|
menu := NewMenu("Config Menu", nil)
|
||||||
|
refreshMainMenu(menu, s)
|
||||||
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
switch event.Key() {
|
||||||
|
case tcell.KeyEsc:
|
||||||
|
s.requestExit()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Rune() == 'q' {
|
||||||
|
s.requestExit()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) refreshMenu(name string, menu *Menu) {
|
||||||
|
switch name {
|
||||||
|
case "main":
|
||||||
|
refreshMainMenu(menu, s)
|
||||||
|
case "model":
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
case "channel":
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshMainMenuIfPresent(s *appState) {
|
||||||
|
if menu, ok := s.menus["main"]; ok {
|
||||||
|
refreshMainMenu(menu, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshMainMenu(menu *Menu, s *appState) {
|
||||||
|
selectedModel := s.selectedModelName()
|
||||||
|
modelReady := selectedModel != ""
|
||||||
|
channelReady := s.hasEnabledChannel()
|
||||||
|
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
|
||||||
|
|
||||||
|
gatewayLabel := "Start Gateway"
|
||||||
|
gatewayDescription := "Launch gateway for channels"
|
||||||
|
if gatewayRunning {
|
||||||
|
gatewayLabel = "Stop Gateway"
|
||||||
|
gatewayDescription = "Gateway running"
|
||||||
|
}
|
||||||
|
|
||||||
|
items := []MenuItem{
|
||||||
|
{
|
||||||
|
Label: rootModelLabel(selectedModel),
|
||||||
|
Description: rootModelDescription(selectedModel),
|
||||||
|
Action: func() {
|
||||||
|
s.push("model", s.modelMenu())
|
||||||
|
},
|
||||||
|
MainColor: func() *tcell.Color {
|
||||||
|
if modelReady {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
color := tcell.ColorGray
|
||||||
|
return &color
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: rootChannelLabel(channelReady),
|
||||||
|
Description: rootChannelDescription(channelReady),
|
||||||
|
Action: func() {
|
||||||
|
s.push("channel", s.channelMenu())
|
||||||
|
},
|
||||||
|
MainColor: func() *tcell.Color {
|
||||||
|
if channelReady {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
color := tcell.ColorGray
|
||||||
|
return &color
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "Start Talk",
|
||||||
|
Description: "Open picoclaw agent in terminal",
|
||||||
|
Action: func() {
|
||||||
|
s.requestStartTalk()
|
||||||
|
},
|
||||||
|
Disabled: !modelReady,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: gatewayLabel,
|
||||||
|
Description: gatewayDescription,
|
||||||
|
Action: func() {
|
||||||
|
if gatewayRunning {
|
||||||
|
s.stopGateway()
|
||||||
|
} else {
|
||||||
|
s.requestStartGateway()
|
||||||
|
}
|
||||||
|
refreshMainMenu(menu, s)
|
||||||
|
},
|
||||||
|
Disabled: !gatewayRunning && (!modelReady || !channelReady),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "View Gateway Log",
|
||||||
|
Description: "Open gateway.log",
|
||||||
|
Action: func() {
|
||||||
|
s.viewGatewayLog()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "Exit",
|
||||||
|
Description: "Exit the TUI",
|
||||||
|
Action: func() {
|
||||||
|
s.requestExit()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
menu.applyItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) applyChangesValidated() bool {
|
||||||
|
if err := s.config.ValidateModelList(); err != nil {
|
||||||
|
s.showMessage("Validation failed", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := s.validateAgentModel(); err != nil {
|
||||||
|
s.showMessage("Validation failed", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := configstore.Save(s.config); err != nil {
|
||||||
|
s.showMessage("Save failed", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile(s.configPath); err == nil {
|
||||||
|
s.original = data
|
||||||
|
s.hasOriginal = true
|
||||||
|
_ = writeBackupConfig(s.backupPath, data)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) requestExit() {
|
||||||
|
if s.dirty {
|
||||||
|
s.confirmApplyOrDiscard(func() {
|
||||||
|
s.app.Stop()
|
||||||
|
}, func() {
|
||||||
|
s.discardChanges()
|
||||||
|
s.app.Stop()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.app.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) requestStartTalk() {
|
||||||
|
if s.dirty {
|
||||||
|
s.confirmApplyOrDiscard(func() {
|
||||||
|
s.startTalk()
|
||||||
|
}, func() {
|
||||||
|
s.startTalk()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.startTalk()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) requestStartGateway() {
|
||||||
|
if s.dirty {
|
||||||
|
s.confirmApplyOrDiscard(func() {
|
||||||
|
s.startGateway()
|
||||||
|
}, func() {
|
||||||
|
s.startGateway()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.startGateway()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) viewGatewayLog() {
|
||||||
|
data, err := os.ReadFile(s.logPath)
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Log not found", "gateway.log not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := tview.NewTextView()
|
||||||
|
text.SetBorder(true).SetTitle("Gateway Log")
|
||||||
|
text.SetText(string(data))
|
||||||
|
text.SetDoneFunc(func(key tcell.Key) {
|
||||||
|
s.pages.RemovePage("log")
|
||||||
|
})
|
||||||
|
text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pages.RemovePage("log")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
s.pages.AddPage("log", text, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) selectedModelName() string {
|
||||||
|
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
if modelName == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !s.isActiveModelValid() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return modelName
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootModelLabel(selected string) string {
|
||||||
|
if selected == "" {
|
||||||
|
return "Model (no model selected)"
|
||||||
|
}
|
||||||
|
return "Model (" + selected + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootModelDescription(selected string) string {
|
||||||
|
if selected == "" {
|
||||||
|
return "no model selected"
|
||||||
|
}
|
||||||
|
return "selected"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootChannelLabel(valid bool) string {
|
||||||
|
if !valid {
|
||||||
|
return "Channel (no channel enabled)"
|
||||||
|
}
|
||||||
|
return "Channel"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootChannelDescription(valid bool) string {
|
||||||
|
if !valid {
|
||||||
|
return "no channel enabled"
|
||||||
|
}
|
||||||
|
return "enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) startTalk() {
|
||||||
|
if !s.isActiveModelValid() {
|
||||||
|
s.showMessage("Model required", "Select a valid model before starting talk")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.applyChangesValidated() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.app.Suspend(func() {
|
||||||
|
cmd := exec.Command("picoclaw", "agent")
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
_ = cmd.Run()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) startGateway() {
|
||||||
|
if !s.isActiveModelValid() {
|
||||||
|
s.showMessage("Model required", "Select a valid model before starting gateway")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.hasEnabledChannel() {
|
||||||
|
s.showMessage("Channel required", "Enable at least one channel before starting gateway")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.applyChangesValidated() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = stopGatewayProcess()
|
||||||
|
cmd := exec.Command("picoclaw", "gateway")
|
||||||
|
logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Gateway failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd.Stdout = logFile
|
||||||
|
cmd.Stderr = logFile
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
s.showMessage("Gateway failed", err.Error())
|
||||||
|
_ = logFile.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = logFile.Close()
|
||||||
|
s.gatewayCmd = cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) stopGateway() {
|
||||||
|
_ = stopGatewayProcess()
|
||||||
|
if s.gatewayCmd != nil && s.gatewayCmd.Process != nil {
|
||||||
|
_ = s.gatewayCmd.Process.Kill()
|
||||||
|
}
|
||||||
|
s.gatewayCmd = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) isGatewayRunning() bool {
|
||||||
|
return isGatewayProcessRunning()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) validateAgentModel() error {
|
||||||
|
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
if modelName == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.config.GetModelConfig(modelName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) isActiveModelValid() bool {
|
||||||
|
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
if modelName == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cfg, err := s.config.GetModelConfig(modelName)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth"
|
||||||
|
hasModel := strings.TrimSpace(cfg.Model) != ""
|
||||||
|
return hasKey && hasModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) hasEnabledChannel() bool {
|
||||||
|
c := s.config.Channels
|
||||||
|
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
|
||||||
|
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
|
||||||
|
c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {
|
||||||
|
if s.pages.HasPage("apply") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText("Apply changes or discard before continuing?").
|
||||||
|
AddButtons([]string{"Cancel", "Discard", "Apply"}).
|
||||||
|
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||||
|
s.pages.RemovePage("apply")
|
||||||
|
switch buttonLabel {
|
||||||
|
case "Discard":
|
||||||
|
s.discardChanges()
|
||||||
|
if onDiscard != nil {
|
||||||
|
onDiscard()
|
||||||
|
}
|
||||||
|
case "Apply":
|
||||||
|
if s.applyChangesValidated() {
|
||||||
|
s.dirty = false
|
||||||
|
if onApply != nil {
|
||||||
|
onApply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
modal.SetBorder(true)
|
||||||
|
s.pages.AddPage("apply", modal, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) discardChanges() {
|
||||||
|
if s.hasOriginal {
|
||||||
|
_ = writeOriginalConfig(s.configPath, s.original)
|
||||||
|
} else {
|
||||||
|
_ = os.Remove(s.configPath)
|
||||||
|
}
|
||||||
|
_ = os.Remove(s.backupPath)
|
||||||
|
if cfg, err := configstore.Load(); err == nil && cfg != nil {
|
||||||
|
s.config = cfg
|
||||||
|
}
|
||||||
|
s.dirty = false
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) showMessage(title, message string) {
|
||||||
|
if s.pages.HasPage("message") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText(strings.TrimSpace(message)).
|
||||||
|
AddButtons([]string{"OK"}).
|
||||||
|
SetDoneFunc(func(_ int, _ string) {
|
||||||
|
s.pages.RemovePage("message")
|
||||||
|
})
|
||||||
|
modal.SetTitle(title).SetBorder(true)
|
||||||
|
modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
|
||||||
|
modal.SetTextColor(tview.Styles.PrimaryTextColor)
|
||||||
|
modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255))
|
||||||
|
modal.SetButtonTextColor(tview.Styles.PrimaryTextColor)
|
||||||
|
s.pages.AddPage("message", modal, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOriginalConfig(path string) ([]byte, bool) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return data, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOriginalConfig(path string, data []byte) error {
|
||||||
|
return os.WriteFile(path, data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBackupConfig(path string, data []byte) error {
|
||||||
|
return os.WriteFile(path, data, 0o600)
|
||||||
|
}
|
||||||
410
cmd/picoclaw-launcher-tui/internal/ui/channel.go
Normal file
410
cmd/picoclaw-launcher-tui/internal/ui/channel.go
Normal file
|
|
@ -0,0 +1,410 @@
|
||||||
|
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{
|
||||||
|
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||||
|
channelItem(
|
||||||
|
"Telegram",
|
||||||
|
"Telegram bot settings",
|
||||||
|
s.config.Channels.Telegram.Enabled,
|
||||||
|
func() { s.push("channel-telegram", s.telegramForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Discord",
|
||||||
|
"Discord bot settings",
|
||||||
|
s.config.Channels.Discord.Enabled,
|
||||||
|
func() { s.push("channel-discord", s.discordForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"QQ",
|
||||||
|
"QQ bot settings",
|
||||||
|
s.config.Channels.QQ.Enabled,
|
||||||
|
func() { s.push("channel-qq", s.qqForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"MaixCam",
|
||||||
|
"MaixCam gateway",
|
||||||
|
s.config.Channels.MaixCam.Enabled,
|
||||||
|
func() { s.push("channel-maixcam", s.maixcamForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WhatsApp",
|
||||||
|
"WhatsApp bridge",
|
||||||
|
s.config.Channels.WhatsApp.Enabled,
|
||||||
|
func() { s.push("channel-whatsapp", s.whatsappForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Feishu",
|
||||||
|
"Feishu bot settings",
|
||||||
|
s.config.Channels.Feishu.Enabled,
|
||||||
|
func() { s.push("channel-feishu", s.feishuForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"DingTalk",
|
||||||
|
"DingTalk bot settings",
|
||||||
|
s.config.Channels.DingTalk.Enabled,
|
||||||
|
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Slack",
|
||||||
|
"Slack bot settings",
|
||||||
|
s.config.Channels.Slack.Enabled,
|
||||||
|
func() { s.push("channel-slack", s.slackForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"LINE",
|
||||||
|
"LINE bot settings",
|
||||||
|
s.config.Channels.LINE.Enabled,
|
||||||
|
func() { s.push("channel-line", s.lineForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"OneBot",
|
||||||
|
"OneBot settings",
|
||||||
|
s.config.Channels.OneBot.Enabled,
|
||||||
|
func() { s.push("channel-onebot", s.onebotForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WeCom",
|
||||||
|
"WeCom bot settings",
|
||||||
|
s.config.Channels.WeCom.Enabled,
|
||||||
|
func() { s.push("channel-wecom", s.wecomForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WeCom App",
|
||||||
|
"WeCom App settings",
|
||||||
|
s.config.Channels.WeComApp.Enabled,
|
||||||
|
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if event.Rune() == 'q' {
|
||||||
|
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) onebotForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.OneBot
|
||||||
|
form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) {
|
||||||
|
cfg.WSUrl = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
|
||||||
|
cfg.AccessToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(
|
||||||
|
form,
|
||||||
|
"Reconnect Interval",
|
||||||
|
cfg.ReconnectInterval,
|
||||||
|
func(value int) { cfg.ReconnectInterval = value },
|
||||||
|
)
|
||||||
|
form.AddInputField(
|
||||||
|
"Group Trigger Prefix",
|
||||||
|
strings.Join(cfg.GroupTriggerPrefix, ","),
|
||||||
|
128,
|
||||||
|
nil,
|
||||||
|
func(text string) {
|
||||||
|
cfg.GroupTriggerPrefix = splitCSV(text)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) wecomForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.WeCom
|
||||||
|
form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||||
|
cfg.Token = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
||||||
|
cfg.EncodingAESKey = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) {
|
||||||
|
cfg.WebhookURL = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookHost = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||||
|
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookPath = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
addIntField(
|
||||||
|
form,
|
||||||
|
"Reply Timeout",
|
||||||
|
cfg.ReplyTimeout,
|
||||||
|
func(value int) { cfg.ReplyTimeout = value },
|
||||||
|
)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) wecomAppForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.WeComApp
|
||||||
|
form := baseChannelForm("WeCom App", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) {
|
||||||
|
cfg.CorpID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) {
|
||||||
|
cfg.CorpSecret = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value })
|
||||||
|
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||||
|
cfg.Token = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
||||||
|
cfg.EncodingAESKey = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookHost = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||||
|
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookPath = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
addIntField(
|
||||||
|
form,
|
||||||
|
"Reply Timeout",
|
||||||
|
cfg.ReplyTimeout,
|
||||||
|
func(value int) { cfg.ReplyTimeout = value },
|
||||||
|
)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) {
|
||||||
|
return func(v bool) {
|
||||||
|
*enabledPtr = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) {
|
||||||
|
form.AddInputField("Allow From", strings.Join(*allowFrom, ","), 128, nil, func(text string) {
|
||||||
|
*allowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form {
|
||||||
|
form := tview.NewForm()
|
||||||
|
form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title))
|
||||||
|
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
||||||
|
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
||||||
|
form.AddCheckbox("Enabled", enabled, func(checked bool) {
|
||||||
|
onEnabled(checked)
|
||||||
|
})
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapWithBack(form *tview.Form, s *appState) tview.Primitive {
|
||||||
|
form.AddButton("Back", func() {
|
||||||
|
s.pop()
|
||||||
|
})
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(input string) picoclawconfig.FlexibleStringSlice {
|
||||||
|
parts := strings.Split(strings.TrimSpace(input), ",")
|
||||||
|
cleaned := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
value := strings.TrimSpace(part)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned = append(cleaned, value)
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
func addIntField(form *tview.Form, label string, value int, onChange func(int)) {
|
||||||
|
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||||
|
var parsed int
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||||
|
onChange(parsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) {
|
||||||
|
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||||
|
var parsed int64
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||||
|
onChange(parsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelItem(label, description string, enabled bool, action MenuAction) MenuItem {
|
||||||
|
item := MenuItem{
|
||||||
|
Label: label,
|
||||||
|
Description: description,
|
||||||
|
Action: action,
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
color := tcell.ColorGray
|
||||||
|
item.MainColor = &color
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
16
cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go
Normal file
16
cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//go:build !windows
|
||||||
|
// +build !windows
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func isGatewayProcessRunning() bool {
|
||||||
|
cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopGatewayProcess() error {
|
||||||
|
cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
16
cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go
Normal file
16
cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//go:build windows
|
||||||
|
// +build windows
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func isGatewayProcessRunning() bool {
|
||||||
|
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe")
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopGatewayProcess() error {
|
||||||
|
cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe")
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
72
cmd/picoclaw-launcher-tui/internal/ui/menu.go
Normal file
72
cmd/picoclaw-launcher-tui/internal/ui/menu.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MenuAction func()
|
||||||
|
|
||||||
|
type MenuItem struct {
|
||||||
|
Label string
|
||||||
|
Description string
|
||||||
|
Action MenuAction
|
||||||
|
Disabled bool
|
||||||
|
MainColor *tcell.Color
|
||||||
|
DescColor *tcell.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
type Menu struct {
|
||||||
|
*tview.Table
|
||||||
|
items []MenuItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMenu(title string, items []MenuItem) *Menu {
|
||||||
|
table := tview.NewTable().SetSelectable(true, false)
|
||||||
|
table.SetBorder(true).SetTitle(title)
|
||||||
|
table.SetBorders(false)
|
||||||
|
menu := &Menu{Table: table, items: items}
|
||||||
|
menu.applyItems(items)
|
||||||
|
menu.SetSelectedFunc(func(row, _ int) {
|
||||||
|
if row < 0 || row >= len(menu.items) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := menu.items[row]
|
||||||
|
if item.Disabled || item.Action == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.Action()
|
||||||
|
})
|
||||||
|
menu.SetSelectedStyle(
|
||||||
|
tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor).
|
||||||
|
Background(tcell.NewRGBColor(189, 147, 249)),
|
||||||
|
)
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Menu) applyItems(items []MenuItem) {
|
||||||
|
m.items = items
|
||||||
|
m.Clear()
|
||||||
|
for row, item := range items {
|
||||||
|
label := item.Label
|
||||||
|
if item.Disabled && label != "" {
|
||||||
|
label = label + " (disabled)"
|
||||||
|
}
|
||||||
|
left := tview.NewTableCell(label)
|
||||||
|
right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight)
|
||||||
|
if item.MainColor != nil {
|
||||||
|
left.SetTextColor(*item.MainColor)
|
||||||
|
}
|
||||||
|
if item.DescColor != nil {
|
||||||
|
right.SetTextColor(*item.DescColor)
|
||||||
|
} else {
|
||||||
|
right.SetTextColor(tview.Styles.TertiaryTextColor)
|
||||||
|
}
|
||||||
|
if item.Disabled {
|
||||||
|
left.SetTextColor(tcell.ColorGray)
|
||||||
|
right.SetTextColor(tcell.ColorGray)
|
||||||
|
}
|
||||||
|
m.SetCell(row, 0, left)
|
||||||
|
m.SetCell(row, 1, right)
|
||||||
|
}
|
||||||
|
}
|
||||||
347
cmd/picoclaw-launcher-tui/internal/ui/model.go
Normal file
347
cmd/picoclaw-launcher-tui/internal/ui/model.go
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *appState) modelMenu() tview.Primitive {
|
||||||
|
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
||||||
|
items = append(items,
|
||||||
|
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||||
|
MenuItem{
|
||||||
|
Label: "Add model",
|
||||||
|
Description: "Append a new model entry",
|
||||||
|
Action: func() {
|
||||||
|
s.addModel(
|
||||||
|
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
||||||
|
)
|
||||||
|
s.push(
|
||||||
|
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||||
|
s.modelForm(len(s.config.ModelList)-1),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
for i := range s.config.ModelList {
|
||||||
|
index := i
|
||||||
|
model := s.config.ModelList[i]
|
||||||
|
isValid := isModelValid(model)
|
||||||
|
desc := model.APIBase
|
||||||
|
if desc == "" {
|
||||||
|
desc = model.AuthMethod
|
||||||
|
}
|
||||||
|
if desc == "" {
|
||||||
|
desc = "api_key required"
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||||
|
if model.ModelName == currentModel && currentModel != "" {
|
||||||
|
label = "* " + label
|
||||||
|
}
|
||||||
|
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||||
|
items = append(items, MenuItem{
|
||||||
|
Label: label,
|
||||||
|
Description: desc,
|
||||||
|
MainColor: modelStatusColor(isValid, isSelected),
|
||||||
|
Action: func() {
|
||||||
|
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
menu := NewMenu("Models", items)
|
||||||
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Rune() == 'q' {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Rune() == ' ' {
|
||||||
|
row, _ := menu.GetSelection()
|
||||||
|
if row > 0 && row <= len(s.config.ModelList) {
|
||||||
|
model := s.config.ModelList[row-1]
|
||||||
|
if !isModelValid(model) {
|
||||||
|
s.showMessage(
|
||||||
|
"Invalid model",
|
||||||
|
"Select a model with api_key or oauth auth_method",
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.config.Agents.Defaults.Model = model.ModelName
|
||||||
|
s.dirty = true
|
||||||
|
refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList)
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) modelForm(index int) tview.Primitive {
|
||||||
|
model := &s.config.ModelList[index]
|
||||||
|
form := tview.NewForm()
|
||||||
|
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||||
|
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
||||||
|
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
||||||
|
|
||||||
|
addInput(form, "Model Name", model.ModelName, func(value string) {
|
||||||
|
model.ModelName = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "Model", model.Model, func(value string) {
|
||||||
|
model.Model = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "API Base", model.APIBase, func(value string) {
|
||||||
|
model.APIBase = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "API Key", model.APIKey, func(value string) {
|
||||||
|
model.APIKey = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "Proxy", model.Proxy, func(value string) {
|
||||||
|
model.Proxy = value
|
||||||
|
})
|
||||||
|
addInput(form, "Auth Method", model.AuthMethod, func(value string) {
|
||||||
|
model.AuthMethod = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "Connect Mode", model.ConnectMode, func(value string) {
|
||||||
|
model.ConnectMode = value
|
||||||
|
})
|
||||||
|
addInput(form, "Workspace", model.Workspace, func(value string) {
|
||||||
|
model.Workspace = value
|
||||||
|
})
|
||||||
|
addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) {
|
||||||
|
model.MaxTokensField = value
|
||||||
|
})
|
||||||
|
addIntInput(form, "RPM", model.RPM, func(value int) {
|
||||||
|
model.RPM = value
|
||||||
|
})
|
||||||
|
addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) {
|
||||||
|
model.RequestTimeout = value
|
||||||
|
})
|
||||||
|
|
||||||
|
form.AddButton("Delete", func() {
|
||||||
|
s.deleteModel(index)
|
||||||
|
})
|
||||||
|
form.AddButton("Test", func() {
|
||||||
|
s.testModel(model)
|
||||||
|
})
|
||||||
|
form.AddButton("Back", func() {
|
||||||
|
s.pop()
|
||||||
|
})
|
||||||
|
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
func addInput(form *tview.Form, label, value string, onChange func(string)) {
|
||||||
|
form.AddInputField(label, value, 128, nil, func(text string) {
|
||||||
|
onChange(strings.TrimSpace(text))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func addIntInput(form *tview.Form, label string, value int, onChange func(int)) {
|
||||||
|
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||||
|
var parsed int
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||||
|
onChange(parsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) addModel(model picoclawconfig.ModelConfig) {
|
||||||
|
s.config.ModelList = append(s.config.ModelList, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) deleteModel(index int) {
|
||||||
|
if index < 0 || index >= len(s.config.ModelList) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...)
|
||||||
|
s.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelStatusColor(valid bool, selected bool) *tcell.Color {
|
||||||
|
if valid {
|
||||||
|
color := tview.Styles.PrimaryTextColor
|
||||||
|
return &color
|
||||||
|
}
|
||||||
|
color := tcell.ColorGray
|
||||||
|
return &color
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
||||||
|
for i, model := range models {
|
||||||
|
row := i + 1
|
||||||
|
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||||
|
isValid := isModelValid(model)
|
||||||
|
if model.ModelName == currentModel && currentModel != "" {
|
||||||
|
label = "* " + label
|
||||||
|
}
|
||||||
|
cell := menu.GetCell(row, 0)
|
||||||
|
if cell != nil {
|
||||||
|
cell.SetText(label)
|
||||||
|
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||||
|
color := modelStatusColor(isValid, isSelected)
|
||||||
|
if color != nil {
|
||||||
|
cell.SetTextColor(*color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||||
|
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
||||||
|
items = append(items,
|
||||||
|
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||||
|
MenuItem{
|
||||||
|
Label: "Add model",
|
||||||
|
Description: "Append a new model entry",
|
||||||
|
Action: func() {
|
||||||
|
s.addModel(
|
||||||
|
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
||||||
|
)
|
||||||
|
s.push(
|
||||||
|
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||||
|
s.modelForm(len(s.config.ModelList)-1),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
for i := range s.config.ModelList {
|
||||||
|
index := i
|
||||||
|
model := s.config.ModelList[i]
|
||||||
|
isValid := isModelValid(model)
|
||||||
|
desc := model.APIBase
|
||||||
|
if desc == "" {
|
||||||
|
desc = model.AuthMethod
|
||||||
|
}
|
||||||
|
if desc == "" {
|
||||||
|
desc = "api_key required"
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||||
|
if model.ModelName == currentModel && currentModel != "" {
|
||||||
|
label = "* " + label
|
||||||
|
}
|
||||||
|
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||||
|
items = append(items, MenuItem{
|
||||||
|
Label: label,
|
||||||
|
Description: desc,
|
||||||
|
MainColor: modelStatusColor(isValid, isSelected),
|
||||||
|
Action: func() {
|
||||||
|
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
menu.applyItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isModelValid(model picoclawconfig.ModelConfig) bool {
|
||||||
|
hasKey := strings.TrimSpace(model.APIKey) != "" ||
|
||||||
|
strings.TrimSpace(model.AuthMethod) == "oauth"
|
||||||
|
hasModel := strings.TrimSpace(model.Model) != ""
|
||||||
|
return hasKey && hasModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
||||||
|
if model == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(model.APIKey) == "" {
|
||||||
|
s.showMessage("Missing API Key", "Set api_key before testing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := strings.TrimSpace(model.APIBase)
|
||||||
|
if base == "" {
|
||||||
|
s.showMessage("Missing API Base", "Set api_base before testing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelID := strings.TrimSpace(model.Model)
|
||||||
|
if modelID == "" {
|
||||||
|
s.showMessage("Missing Model", "Set model before testing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(modelID, "openai/") {
|
||||||
|
s.showMessage("Unsupported model", "Only openai/* models are supported for test")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelName := strings.TrimPrefix(modelID, "openai/")
|
||||||
|
endpoint := strings.TrimRight(base, "/") + "/chat/completions"
|
||||||
|
|
||||||
|
payload := fmt.Sprintf(
|
||||||
|
`{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`,
|
||||||
|
modelName,
|
||||||
|
)
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Test failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey))
|
||||||
|
|
||||||
|
resp, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Test failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
s.showMessage("Test OK", resp.Status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, 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))),
|
||||||
|
)
|
||||||
|
}
|
||||||
43
cmd/picoclaw-launcher-tui/internal/ui/style.go
Normal file
43
cmd/picoclaw-launcher-tui/internal/ui/style.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
15
cmd/picoclaw-launcher-tui/main.go
Normal file
15
cmd/picoclaw-launcher-tui/main.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := ui.Run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
290
cmd/picoclaw-launcher/README.md
Normal file
290
cmd/picoclaw-launcher/README.md
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
# PicoClaw Launcher
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> This project is a temporary solution and will be refactored in the future to provide a complete web service. Therefore, the APIs in this directory are not stable.
|
||||||
|
|
||||||
|
A standalone launcher for PicoClaw, providing visual JSON editing and OAuth provider authentication management.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 📝 **Config Editor** — Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor
|
||||||
|
- 🤖 **Model Management** — Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation
|
||||||
|
- 📡 **Channel Configuration** — Form-based settings for 12 channel types (Telegram, Discord, Slack, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links
|
||||||
|
- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth)
|
||||||
|
- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies
|
||||||
|
- 🌍 **i18n** — Chinese/English language switching with browser auto-detection
|
||||||
|
- 🎨 **Theme** — Light / Dark / System theme toggle with localStorage persistence
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||||
|
|
||||||
|
# Run with default config path (~/.picoclaw/config.json)
|
||||||
|
./picoclaw-launcher
|
||||||
|
|
||||||
|
# Specify a config file
|
||||||
|
./picoclaw-launcher ./config.json
|
||||||
|
|
||||||
|
# Allow LAN access
|
||||||
|
./picoclaw-launcher -public
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:18800` in your browser.
|
||||||
|
|
||||||
|
## CLI Options
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: picoclaw-config [options] [config.json]
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
config.json Path to the configuration file (default: ~/.picoclaw/config.json)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-public Listen on all interfaces (0.0.0.0), allowing access from other devices
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
Base URL: `http://localhost:18800`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Static Files
|
||||||
|
|
||||||
|
#### GET /
|
||||||
|
|
||||||
|
Serves the embedded frontend (`index.html`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Config API
|
||||||
|
|
||||||
|
#### GET /api/config
|
||||||
|
|
||||||
|
Reads the current configuration file.
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"config": { ... },
|
||||||
|
"path": "/Users/xiao/.picoclaw/config.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### PUT /api/config
|
||||||
|
|
||||||
|
Saves the configuration. The request body must be a complete Config JSON object.
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": { "defaults": { "model_name": "gpt-5.2" } },
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"auth_method": "oauth"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error** `400 Bad Request` — Invalid JSON
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Auth API
|
||||||
|
|
||||||
|
#### GET /api/auth/status
|
||||||
|
|
||||||
|
Returns the authentication status of all providers and any in-progress device code login.
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"provider": "openai",
|
||||||
|
"auth_method": "oauth",
|
||||||
|
"status": "active",
|
||||||
|
"account_id": "user-xxx",
|
||||||
|
"expires_at": "2026-03-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pending_device": {
|
||||||
|
"provider": "openai",
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": "https://auth.openai.com/activate",
|
||||||
|
"user_code": "ABCD-1234"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` values: `active` | `expired` | `needs_refresh`
|
||||||
|
|
||||||
|
`pending_device` is only present when a device code login is in progress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/auth/login
|
||||||
|
|
||||||
|
Initiates a provider login.
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "openai" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported `provider` values: `openai` | `anthropic` | `google-antigravity`
|
||||||
|
|
||||||
|
##### OpenAI (Device Code Flow)
|
||||||
|
|
||||||
|
Returns device code info. The server polls for completion in the background.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": "https://auth.openai.com/activate",
|
||||||
|
"user_code": "ABCD-1234",
|
||||||
|
"message": "Open the URL and enter the code to authenticate."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The user opens `device_url` in a browser and enters `user_code`. Once authenticated, `GET /api/auth/status` will show `pending_device.status` as `success`.
|
||||||
|
|
||||||
|
##### Anthropic (API Token)
|
||||||
|
|
||||||
|
Requires a `token` field in the request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "anthropic", "token": "sk-ant-xxx" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "success", "message": "Anthropic token saved" }
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Google Antigravity (Browser OAuth)
|
||||||
|
|
||||||
|
Returns an authorization URL for the frontend to open in a new tab:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "redirect",
|
||||||
|
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
|
||||||
|
"message": "Open the URL to authenticate with Google."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After authentication, Google redirects to `GET /auth/callback`, which saves the credentials and redirects back to the picoclaw-config UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/auth/logout
|
||||||
|
|
||||||
|
Logs out from a provider.
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "openai" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Omit or leave `provider` empty to log out from all providers.
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET /auth/callback
|
||||||
|
|
||||||
|
OAuth browser callback endpoint (used by Google Antigravity). Called by the OAuth provider's redirect — **not invoked directly by the frontend**.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `state` — OAuth state for CSRF validation
|
||||||
|
- `code` — Authorization code
|
||||||
|
|
||||||
|
On success, redirects to `/#auth`.
|
||||||
|
|
||||||
|
|
||||||
|
### Process API
|
||||||
|
|
||||||
|
#### GET /api/process/status
|
||||||
|
|
||||||
|
Gets the running status of the `picoclaw gateway` process.
|
||||||
|
|
||||||
|
**Response** `200 OK` (Running)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"process_status": "running",
|
||||||
|
"status": "ok",
|
||||||
|
"uptime": "1.010814s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** `200 OK` (Stopped)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"process_status": "stopped",
|
||||||
|
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/process/start
|
||||||
|
|
||||||
|
Starts the `picoclaw gateway` process in the background.
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"pid": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/process/stop
|
||||||
|
|
||||||
|
Stops the running `picoclaw gateway` process.
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -v ./cmd/picoclaw-launcher/
|
||||||
|
```
|
||||||
287
cmd/picoclaw-launcher/README.zh.md
Normal file
287
cmd/picoclaw-launcher/README.zh.md
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
# PicoClaw Launcher
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> 该项目属于临时解决方案,后续会重构并提供完整的 Web 服务,因此该目录下的接口并不稳定。
|
||||||
|
|
||||||
|
PicoClaw 的独立启动器,提供可视化 JSON 配置编辑和 OAuth Provider 认证管理。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 📝 **配置编辑** — 侧边栏式设置 UI,支持模型管理、通道配置表单和原始 JSON 编辑器
|
||||||
|
- 🤖 **模型管理** — 模型卡片网格,可用性状态显示(无 API Key 时灰色),主模型选择,增删改查,必填/选填字段分离
|
||||||
|
- 📡 **通道配置** — 12 种通道类型(Telegram、Discord、Slack、企业微信、钉钉、飞书、LINE、WhatsApp、QQ、OneBot、MaixCAM 等)的表单化配置,附带文档链接
|
||||||
|
- 🔐 **Provider 认证** — 支持 OpenAI (Device Code)、Anthropic (API Token)、Google Antigravity (Browser OAuth) 登录
|
||||||
|
- 🌐 **嵌入式前端** — 编译为单一二进制文件,无需额外依赖
|
||||||
|
- 🌍 **国际化** — 中英文切换,首次访问自动检测浏览器语言
|
||||||
|
- 🎨 **主题** — 亮色 / 暗色 / 跟随系统,偏好保存在 localStorage
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 编译
|
||||||
|
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||||
|
|
||||||
|
# 运行(使用默认配置路径 ~/.picoclaw/config.json)
|
||||||
|
./picoclaw-launcher
|
||||||
|
|
||||||
|
# 指定配置文件
|
||||||
|
./picoclaw-launcher ./config.json
|
||||||
|
|
||||||
|
# 允许局域网访问
|
||||||
|
./picoclaw-launcher -public
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后在浏览器中打开 `http://localhost:18800`。
|
||||||
|
|
||||||
|
## 命令行参数
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: picoclaw-launcher [options] [config.json]
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
config.json 配置文件路径(默认: ~/.picoclaw/config.json)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-public 监听所有网络接口(0.0.0.0),允许局域网设备访问
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 文档
|
||||||
|
|
||||||
|
Base URL: `http://localhost:18800`
|
||||||
|
|
||||||
|
### 静态文件
|
||||||
|
|
||||||
|
#### GET /
|
||||||
|
|
||||||
|
提供嵌入式前端页面(`index.html`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Config API
|
||||||
|
|
||||||
|
#### GET /api/config
|
||||||
|
|
||||||
|
读取当前配置文件内容。
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"config": { ... },
|
||||||
|
"path": "/Users/xiao/.picoclaw/config.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### PUT /api/config
|
||||||
|
|
||||||
|
保存配置。请求体为完整的 Config JSON。
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": { "defaults": { "model_name": "gpt-5.2" } },
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"auth_method": "oauth"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error** `400 Bad Request` — 无效 JSON
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Auth API
|
||||||
|
|
||||||
|
#### GET /api/auth/status
|
||||||
|
|
||||||
|
获取所有 Provider 的认证状态和进行中的 Device Code 登录信息。
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"provider": "openai",
|
||||||
|
"auth_method": "oauth",
|
||||||
|
"status": "active",
|
||||||
|
"account_id": "user-xxx",
|
||||||
|
"expires_at": "2026-03-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pending_device": {
|
||||||
|
"provider": "openai",
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": "https://auth.openai.com/activate",
|
||||||
|
"user_code": "ABCD-1234"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` 可选值: `active` | `expired` | `needs_refresh`
|
||||||
|
|
||||||
|
`pending_device` 仅在有进行中的 Device Code 登录时返回。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/auth/login
|
||||||
|
|
||||||
|
发起 Provider 登录。
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "openai" }
|
||||||
|
```
|
||||||
|
|
||||||
|
支持的 `provider` 值: `openai` | `anthropic` | `google-antigravity`
|
||||||
|
|
||||||
|
##### OpenAI (Device Code Flow)
|
||||||
|
|
||||||
|
返回 Device Code 信息,后台自动轮询认证结果:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": "https://auth.openai.com/activate",
|
||||||
|
"user_code": "ABCD-1234",
|
||||||
|
"message": "Open the URL and enter the code to authenticate."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
用户在浏览器中打开 `device_url` 并输入 `user_code`。认证完成后通过 `GET /api/auth/status` 的 `pending_device.status` 变为 `success` 通知前端。
|
||||||
|
|
||||||
|
##### Anthropic (API Token)
|
||||||
|
|
||||||
|
需在请求中附带 token:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "anthropic", "token": "sk-ant-xxx" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "success", "message": "Anthropic token saved" }
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Google Antigravity (Browser OAuth)
|
||||||
|
|
||||||
|
返回授权 URL,前端打开新标签页:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "redirect",
|
||||||
|
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
|
||||||
|
"message": "Open the URL to authenticate with Google."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
认证完成后 Google 回调至 `GET /auth/callback`,自动保存凭据并重定向回 picoclaw-config 页面。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/auth/logout
|
||||||
|
|
||||||
|
登出 Provider。
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "openai" }
|
||||||
|
```
|
||||||
|
|
||||||
|
传空字符串或省略 `provider` 则登出所有 Provider。
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET /auth/callback
|
||||||
|
|
||||||
|
OAuth Browser 回调端点(Google Antigravity 专用),由 OAuth Provider 重定向调用,**非前端直接使用**。
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `state` — OAuth state 校验
|
||||||
|
- `code` — 授权码
|
||||||
|
|
||||||
|
认证成功后重定向到 `/#auth`。
|
||||||
|
|
||||||
|
### Process API
|
||||||
|
|
||||||
|
#### GET /api/process/status
|
||||||
|
|
||||||
|
获取 `picoclaw gateway` 进程的运行状态。
|
||||||
|
|
||||||
|
**Response** `200 OK` (运行中)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"process_status": "running",
|
||||||
|
"status": "ok",
|
||||||
|
"uptime": "1.010814s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** `200 OK` (未运行)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"process_status": "stopped",
|
||||||
|
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/process/start
|
||||||
|
|
||||||
|
在后台启动 `picoclaw gateway` 进程。
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"pid": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/process/stop
|
||||||
|
|
||||||
|
停止正在运行的 `picoclaw gateway` 进程。
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -v ./cmd/picoclaw-launcher/
|
||||||
|
```
|
||||||
BIN
cmd/picoclaw-launcher/icon.ico
Normal file
BIN
cmd/picoclaw-launcher/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
147
cmd/picoclaw-launcher/internal/server/auth_config.go
Normal file
147
cmd/picoclaw-launcher/internal/server/auth_config.go
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// updateConfigAfterLogin updates config.json after a successful provider login.
|
||||||
|
func updateConfigAfterLogin(configPath, provider string, cred *auth.AuthCredential) {
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: could not load config to update auth_method: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch provider {
|
||||||
|
case "openai":
|
||||||
|
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||||
|
found := false
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
if isOpenAIModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = "oauth"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "gpt-5.2",
|
||||||
|
Model: "openai/gpt-5.2",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||||
|
|
||||||
|
case "anthropic":
|
||||||
|
cfg.Providers.Anthropic.AuthMethod = "token"
|
||||||
|
found := false
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
if isAnthropicModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = "token"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "claude-sonnet-4.6",
|
||||||
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
|
AuthMethod: "token",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
||||||
|
|
||||||
|
case "google-antigravity":
|
||||||
|
cfg.Providers.Antigravity.AuthMethod = "oauth"
|
||||||
|
found := false
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
if isAntigravityModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = "oauth"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "gemini-flash",
|
||||||
|
Model: "antigravity/gemini-3-flash",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cfg.Agents.Defaults.ModelName = "gemini-flash"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
log.Printf("Warning: could not update config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearAuthMethodInConfig clears auth_method for a specific provider in config.json.
|
||||||
|
func clearAuthMethodInConfig(configPath, provider string) {
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
switch provider {
|
||||||
|
case "openai":
|
||||||
|
if isOpenAIModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
|
case "anthropic":
|
||||||
|
if isAnthropicModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
|
case "google-antigravity", "antigravity":
|
||||||
|
if isAntigravityModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch provider {
|
||||||
|
case "openai":
|
||||||
|
cfg.Providers.OpenAI.AuthMethod = ""
|
||||||
|
case "anthropic":
|
||||||
|
cfg.Providers.Anthropic.AuthMethod = ""
|
||||||
|
case "google-antigravity", "antigravity":
|
||||||
|
cfg.Providers.Antigravity.AuthMethod = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
config.SaveConfig(configPath, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearAllAuthMethodsInConfig clears auth_method for all providers in config.json.
|
||||||
|
func clearAllAuthMethodsInConfig(configPath string) {
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
cfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
|
cfg.Providers.OpenAI.AuthMethod = ""
|
||||||
|
cfg.Providers.Anthropic.AuthMethod = ""
|
||||||
|
cfg.Providers.Antigravity.AuthMethod = ""
|
||||||
|
config.SaveConfig(configPath, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Model identification helpers ─────────────────────────────────
|
||||||
|
|
||||||
|
func isOpenAIModel(model string) bool {
|
||||||
|
return model == "openai" || strings.HasPrefix(model, "openai/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAnthropicModel(model string) bool {
|
||||||
|
return model == "anthropic" || strings.HasPrefix(model, "anthropic/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAntigravityModel(model string) bool {
|
||||||
|
return model == "antigravity" || model == "google-antigravity" ||
|
||||||
|
strings.HasPrefix(model, "antigravity/") || strings.HasPrefix(model, "google-antigravity/")
|
||||||
|
}
|
||||||
222
cmd/picoclaw-launcher/internal/server/auth_config_test.go
Normal file
222
cmd/picoclaw-launcher/internal/server/auth_config_test.go
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Model identification helpers ─────────────────────────────────
|
||||||
|
|
||||||
|
func TestIsOpenAIModel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
model string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"openai", true},
|
||||||
|
{"openai/gpt-4o", true},
|
||||||
|
{"openai/gpt-5.2", true},
|
||||||
|
{"anthropic", false},
|
||||||
|
{"anthropic/claude-sonnet-4.6", false},
|
||||||
|
{"openai-compatible", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := isOpenAIModel(tt.model); got != tt.want {
|
||||||
|
t.Errorf("isOpenAIModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAnthropicModel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
model string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"anthropic", true},
|
||||||
|
{"anthropic/claude-sonnet-4.6", true},
|
||||||
|
{"openai", false},
|
||||||
|
{"openai/gpt-4o", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := isAnthropicModel(tt.model); got != tt.want {
|
||||||
|
t.Errorf("isAnthropicModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAntigravityModel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
model string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"antigravity", true},
|
||||||
|
{"google-antigravity", true},
|
||||||
|
{"antigravity/gemini-3-flash", true},
|
||||||
|
{"google-antigravity/gemini-3-flash", true},
|
||||||
|
{"openai", false},
|
||||||
|
{"antigravity-custom", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := isAntigravityModel(tt.model); got != tt.want {
|
||||||
|
t.Errorf("isAntigravityModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config update helpers ────────────────────────────────────────
|
||||||
|
|
||||||
|
func writeTempConfigViaSave(t *testing.T, cfg *config.Config) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.json")
|
||||||
|
if err := config.SaveConfig(path, cfg); err != nil {
|
||||||
|
t.Fatalf("save config: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTempConfig(t *testing.T, path string) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := config.LoadConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigAfterLogin_OpenAI_ExistingModel(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||||
|
updateConfigAfterLogin(path, "openai", cred)
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
// Model-level auth_method persists through serialization
|
||||||
|
if len(result.ModelList) != 1 {
|
||||||
|
t.Fatalf("expected 1 model, got %d", len(result.ModelList))
|
||||||
|
}
|
||||||
|
if result.ModelList[0].AuthMethod != "oauth" {
|
||||||
|
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigAfterLogin_OpenAI_NoExistingModel(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||||
|
updateConfigAfterLogin(path, "openai", cred)
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
if len(result.ModelList) != 2 {
|
||||||
|
t.Fatalf("expected 2 models (original + added), got %d", len(result.ModelList))
|
||||||
|
}
|
||||||
|
if result.ModelList[1].Model != "openai/gpt-5.2" {
|
||||||
|
t.Errorf("expected added model openai/gpt-5.2, got %q", result.ModelList[1].Model)
|
||||||
|
}
|
||||||
|
if result.Agents.Defaults.ModelName != "gpt-5.2" {
|
||||||
|
t.Errorf("expected default model_name=gpt-5.2, got %q", result.Agents.Defaults.ModelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigAfterLogin_Anthropic(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
cred := &auth.AuthCredential{AuthMethod: "token"}
|
||||||
|
updateConfigAfterLogin(path, "anthropic", cred)
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
// Model should be added with correct auth_method
|
||||||
|
if len(result.ModelList) != 1 {
|
||||||
|
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
|
||||||
|
}
|
||||||
|
if result.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||||
|
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", result.ModelList[0].Model)
|
||||||
|
}
|
||||||
|
if result.ModelList[0].AuthMethod != "token" {
|
||||||
|
t.Errorf("expected model auth_method=token, got %q", result.ModelList[0].AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigAfterLogin_GoogleAntigravity(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||||
|
updateConfigAfterLogin(path, "google-antigravity", cred)
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
// Model should be added with correct auth_method
|
||||||
|
if len(result.ModelList) != 1 {
|
||||||
|
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
|
||||||
|
}
|
||||||
|
if result.ModelList[0].Model != "antigravity/gemini-3-flash" {
|
||||||
|
t.Errorf("expected model antigravity/gemini-3-flash, got %q", result.ModelList[0].Model)
|
||||||
|
}
|
||||||
|
if result.ModelList[0].AuthMethod != "oauth" {
|
||||||
|
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearAuthMethodInConfig(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
|
||||||
|
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
clearAuthMethodInConfig(path, "openai")
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
// Openai model auth_method should be cleared
|
||||||
|
if result.ModelList[0].AuthMethod != "" {
|
||||||
|
t.Errorf("expected openai model auth_method cleared, got %q", result.ModelList[0].AuthMethod)
|
||||||
|
}
|
||||||
|
// Anthropic model should be unchanged
|
||||||
|
if result.ModelList[1].AuthMethod != "token" {
|
||||||
|
t.Errorf("expected anthropic model auth_method unchanged, got %q", result.ModelList[1].AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearAllAuthMethodsInConfig(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
|
||||||
|
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||||
|
{ModelName: "gemini", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
clearAllAuthMethodsInConfig(path)
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
for i, m := range result.ModelList {
|
||||||
|
if m.AuthMethod != "" {
|
||||||
|
t.Errorf("model[%d] auth_method not cleared, got %q", i, m.AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
315
cmd/picoclaw-launcher/internal/server/auth_handlers.go
Normal file
315
cmd/picoclaw-launcher/internal/server/auth_handlers.go
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// oauthSession stores in-flight OAuth state for browser-based flows.
|
||||||
|
type oauthSession struct {
|
||||||
|
Provider string
|
||||||
|
PKCE auth.PKCECodes
|
||||||
|
State string
|
||||||
|
RedirectURI string
|
||||||
|
OAuthCfg auth.OAuthProviderConfig
|
||||||
|
ConfigPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceCodeSession stores in-flight device code flow state.
|
||||||
|
type deviceCodeSession struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
Provider string
|
||||||
|
Info *auth.DeviceCodeInfo
|
||||||
|
OAuthCfg auth.OAuthProviderConfig
|
||||||
|
ConfigPath string
|
||||||
|
Status string // "pending", "success", "error"
|
||||||
|
Error string
|
||||||
|
Done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
oauthSessions = map[string]*oauthSession{} // keyed by state
|
||||||
|
oauthSessionsMu sync.Mutex
|
||||||
|
|
||||||
|
activeDeviceSession *deviceCodeSession
|
||||||
|
activeDeviceSessionMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleOpenAILogin starts the OpenAI device code flow and returns device code info to the frontend.
|
||||||
|
func handleOpenAILogin(w http.ResponseWriter, configPath string) {
|
||||||
|
// Check if there's already a pending device code session
|
||||||
|
activeDeviceSessionMu.Lock()
|
||||||
|
if activeDeviceSession != nil {
|
||||||
|
activeDeviceSession.mu.Lock()
|
||||||
|
if !activeDeviceSession.Done {
|
||||||
|
resp := map[string]any{
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": activeDeviceSession.Info.VerifyURL,
|
||||||
|
"user_code": activeDeviceSession.Info.UserCode,
|
||||||
|
"message": "Device code flow already in progress. Enter the code in your browser.",
|
||||||
|
}
|
||||||
|
activeDeviceSession.mu.Unlock()
|
||||||
|
activeDeviceSessionMu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeDeviceSession.mu.Unlock()
|
||||||
|
}
|
||||||
|
activeDeviceSessionMu.Unlock()
|
||||||
|
|
||||||
|
// Request a device code
|
||||||
|
oauthCfg := auth.OpenAIOAuthConfig()
|
||||||
|
info, err := auth.RequestDeviceCode(oauthCfg)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to request device code: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &deviceCodeSession{
|
||||||
|
Provider: "openai",
|
||||||
|
Info: info,
|
||||||
|
OAuthCfg: oauthCfg,
|
||||||
|
ConfigPath: configPath,
|
||||||
|
Status: "pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
activeDeviceSessionMu.Lock()
|
||||||
|
activeDeviceSession = session
|
||||||
|
activeDeviceSessionMu.Unlock()
|
||||||
|
|
||||||
|
// Start background polling
|
||||||
|
go func() {
|
||||||
|
deadline := time.After(15 * time.Minute)
|
||||||
|
ticker := time.NewTicker(time.Duration(info.Interval) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
session.mu.Lock()
|
||||||
|
session.Status = "error"
|
||||||
|
session.Error = "Authentication timed out after 15 minutes"
|
||||||
|
session.Done = true
|
||||||
|
session.mu.Unlock()
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
cred, err := auth.PollDeviceCodeOnce(oauthCfg, info.DeviceAuthID, info.UserCode)
|
||||||
|
if err != nil {
|
||||||
|
continue // Still pending
|
||||||
|
}
|
||||||
|
if cred != nil {
|
||||||
|
if saveErr := auth.SetCredential("openai", cred); saveErr != nil {
|
||||||
|
session.mu.Lock()
|
||||||
|
session.Status = "error"
|
||||||
|
session.Error = saveErr.Error()
|
||||||
|
session.Done = true
|
||||||
|
session.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateConfigAfterLogin(configPath, "openai", cred)
|
||||||
|
session.mu.Lock()
|
||||||
|
session.Status = "success"
|
||||||
|
session.Done = true
|
||||||
|
session.mu.Unlock()
|
||||||
|
log.Printf("OpenAI device code login successful (account: %s)", cred.AccountID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Return device code info to frontend
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": info.VerifyURL,
|
||||||
|
"user_code": info.UserCode,
|
||||||
|
"message": "Open the URL and enter the code to authenticate.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAnthropicLogin saves a pasted API token for Anthropic.
|
||||||
|
func handleAnthropicLogin(w http.ResponseWriter, token, configPath string) {
|
||||||
|
if token == "" {
|
||||||
|
http.Error(w, "Token is required for Anthropic login", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cred := &auth.AuthCredential{
|
||||||
|
AccessToken: token,
|
||||||
|
Provider: "anthropic",
|
||||||
|
AuthMethod: "token",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.SetCredential("anthropic", cred); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to save credentials: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfigAfterLogin(configPath, "anthropic", cred)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Anthropic token saved",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGoogleAntigravityLogin generates a PKCE + auth URL and returns it to the frontend.
|
||||||
|
func handleGoogleAntigravityLogin(w http.ResponseWriter, r *http.Request, configPath string) {
|
||||||
|
oauthCfg := auth.GoogleAntigravityOAuthConfig()
|
||||||
|
|
||||||
|
pkce, err := auth.GeneratePKCE()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to generate PKCE: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := auth.GenerateState()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to generate state: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build redirect URI pointing to picoclaw-launcher's own callback
|
||||||
|
scheme := "http"
|
||||||
|
redirectURI := fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host)
|
||||||
|
|
||||||
|
authURL := auth.BuildAuthorizeURL(oauthCfg, pkce, state, redirectURI)
|
||||||
|
|
||||||
|
// Store session for callback
|
||||||
|
oauthSessionsMu.Lock()
|
||||||
|
oauthSessions[state] = &oauthSession{
|
||||||
|
Provider: "google-antigravity",
|
||||||
|
PKCE: pkce,
|
||||||
|
State: state,
|
||||||
|
RedirectURI: redirectURI,
|
||||||
|
OAuthCfg: oauthCfg,
|
||||||
|
ConfigPath: configPath,
|
||||||
|
}
|
||||||
|
oauthSessionsMu.Unlock()
|
||||||
|
|
||||||
|
// Clean up stale sessions after 10 minutes
|
||||||
|
go func() {
|
||||||
|
time.Sleep(10 * time.Minute)
|
||||||
|
oauthSessionsMu.Lock()
|
||||||
|
delete(oauthSessions, state)
|
||||||
|
oauthSessionsMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"status": "redirect",
|
||||||
|
"auth_url": authURL,
|
||||||
|
"message": "Open the URL to authenticate with Google.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOAuthCallback processes the OAuth callback from Google Antigravity.
|
||||||
|
func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state := r.URL.Query().Get("state")
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
|
||||||
|
oauthSessionsMu.Lock()
|
||||||
|
session, ok := oauthSessions[state]
|
||||||
|
if ok {
|
||||||
|
delete(oauthSessions, state)
|
||||||
|
}
|
||||||
|
oauthSessionsMu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Invalid or expired OAuth state", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if code == "" {
|
||||||
|
errMsg := r.URL.Query().Get("error")
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
|
||||||
|
errMsg,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cred, err := auth.ExchangeCodeForTokens(session.OAuthCfg, code, session.PKCE.CodeVerifier, session.RedirectURI)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cred.Provider = session.Provider
|
||||||
|
|
||||||
|
// Fetch user info for Google Antigravity
|
||||||
|
if session.Provider == "google-antigravity" {
|
||||||
|
if email, err := fetchGoogleUserEmail(cred.AccessToken); err == nil {
|
||||||
|
cred.Email = email
|
||||||
|
}
|
||||||
|
if projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken); err == nil {
|
||||||
|
cred.ProjectID = projectID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.SetCredential(session.Provider, cred); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprintf(w, `<html><body><h2>Failed to save credentials</h2><p>%s</p></body></html>`, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfigAfterLogin(session.ConfigPath, session.Provider, cred)
|
||||||
|
|
||||||
|
// Redirect back to picoclaw-launcher UI
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprintf(w, `<html><body>
|
||||||
|
<h2>Authentication successful!</h2>
|
||||||
|
<p>Redirecting back to Config Editor...</p>
|
||||||
|
<script>setTimeout(function(){ window.location.href = '/#auth'; }, 1000);</script>
|
||||||
|
</body></html>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchGoogleUserEmail retrieves the user's email from Google's userinfo endpoint.
|
||||||
|
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
|
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading userinfo response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var userInfo struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return userInfo.Email, nil
|
||||||
|
}
|
||||||
99
cmd/picoclaw-launcher/internal/server/logbuffer.go
Normal file
99
cmd/picoclaw-launcher/internal/server/logbuffer.go
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// LogBuffer is a thread-safe ring buffer that stores the most recent N log lines.
|
||||||
|
// It supports incremental reads via LinesSince and tracks a runID that increments
|
||||||
|
// on each Reset (used to detect gateway restarts).
|
||||||
|
type LogBuffer struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
lines []string
|
||||||
|
cap int
|
||||||
|
total int // total lines ever appended in current run
|
||||||
|
runID int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogBuffer creates a LogBuffer with the given capacity.
|
||||||
|
func NewLogBuffer(capacity int) *LogBuffer {
|
||||||
|
return &LogBuffer{
|
||||||
|
lines: make([]string, 0, capacity),
|
||||||
|
cap: capacity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append adds a line to the buffer. If the buffer is full, the oldest line is evicted.
|
||||||
|
func (b *LogBuffer) Append(line string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
if len(b.lines) < b.cap {
|
||||||
|
b.lines = append(b.lines, line)
|
||||||
|
} else {
|
||||||
|
b.lines[b.total%b.cap] = line
|
||||||
|
}
|
||||||
|
|
||||||
|
b.total++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears the buffer and increments the runID. Call this when starting a new gateway process.
|
||||||
|
func (b *LogBuffer) Reset() {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
b.lines = b.lines[:0]
|
||||||
|
b.total = 0
|
||||||
|
b.runID++
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinesSince returns lines appended after the given offset, the current total count, and the runID.
|
||||||
|
// If offset >= total, no lines are returned. If offset is too old (evicted), all buffered lines are returned.
|
||||||
|
func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
total = b.total
|
||||||
|
runID = b.runID
|
||||||
|
|
||||||
|
if offset >= b.total {
|
||||||
|
return nil, total, runID
|
||||||
|
}
|
||||||
|
|
||||||
|
buffered := len(b.lines)
|
||||||
|
|
||||||
|
// How many new lines since offset
|
||||||
|
newCount := b.total - offset
|
||||||
|
if newCount > buffered {
|
||||||
|
newCount = buffered
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, newCount)
|
||||||
|
|
||||||
|
if b.total <= b.cap {
|
||||||
|
// Buffer hasn't wrapped yet — simple slice
|
||||||
|
copy(result, b.lines[buffered-newCount:])
|
||||||
|
} else {
|
||||||
|
// Buffer has wrapped — read from ring
|
||||||
|
start := (b.total - newCount) % b.cap
|
||||||
|
for i := range newCount {
|
||||||
|
result[i] = b.lines[(start+i)%b.cap]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, total, runID
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunID returns the current run identifier.
|
||||||
|
func (b *LogBuffer) RunID() int {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
return b.runID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total returns the total number of lines appended in the current run.
|
||||||
|
func (b *LogBuffer) Total() int {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
return b.total
|
||||||
|
}
|
||||||
116
cmd/picoclaw-launcher/internal/server/logbuffer_test.go
Normal file
116
cmd/picoclaw-launcher/internal/server/logbuffer_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogBuffer_Basic(t *testing.T) {
|
||||||
|
buf := NewLogBuffer(5)
|
||||||
|
|
||||||
|
// Empty buffer
|
||||||
|
lines, total, runID := buf.LinesSince(0)
|
||||||
|
assert.Nil(t, lines)
|
||||||
|
assert.Equal(t, 0, total)
|
||||||
|
assert.Equal(t, 0, runID)
|
||||||
|
|
||||||
|
// Append some lines
|
||||||
|
buf.Append("line1")
|
||||||
|
buf.Append("line2")
|
||||||
|
buf.Append("line3")
|
||||||
|
|
||||||
|
lines, total, runID = buf.LinesSince(0)
|
||||||
|
assert.Equal(t, []string{"line1", "line2", "line3"}, lines)
|
||||||
|
assert.Equal(t, 3, total)
|
||||||
|
assert.Equal(t, 0, runID)
|
||||||
|
|
||||||
|
// Incremental read
|
||||||
|
lines, total, _ = buf.LinesSince(2)
|
||||||
|
assert.Equal(t, []string{"line3"}, lines)
|
||||||
|
assert.Equal(t, 3, total)
|
||||||
|
|
||||||
|
// No new lines
|
||||||
|
lines, total, _ = buf.LinesSince(3)
|
||||||
|
assert.Nil(t, lines)
|
||||||
|
assert.Equal(t, 3, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogBuffer_Wrap(t *testing.T) {
|
||||||
|
buf := NewLogBuffer(3)
|
||||||
|
|
||||||
|
buf.Append("a")
|
||||||
|
buf.Append("b")
|
||||||
|
buf.Append("c")
|
||||||
|
buf.Append("d") // evicts "a"
|
||||||
|
buf.Append("e") // evicts "b"
|
||||||
|
|
||||||
|
lines, total, _ := buf.LinesSince(0)
|
||||||
|
assert.Equal(t, []string{"c", "d", "e"}, lines)
|
||||||
|
assert.Equal(t, 5, total)
|
||||||
|
|
||||||
|
// Incremental after wrap
|
||||||
|
lines, total, _ = buf.LinesSince(3)
|
||||||
|
assert.Equal(t, []string{"d", "e"}, lines)
|
||||||
|
assert.Equal(t, 5, total)
|
||||||
|
|
||||||
|
// Offset too old (before buffer start), get all buffered
|
||||||
|
lines, total, _ = buf.LinesSince(1)
|
||||||
|
assert.Equal(t, []string{"c", "d", "e"}, lines)
|
||||||
|
assert.Equal(t, 5, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogBuffer_Reset(t *testing.T) {
|
||||||
|
buf := NewLogBuffer(5)
|
||||||
|
|
||||||
|
buf.Append("before")
|
||||||
|
assert.Equal(t, 0, buf.RunID())
|
||||||
|
|
||||||
|
buf.Reset()
|
||||||
|
assert.Equal(t, 1, buf.RunID())
|
||||||
|
assert.Equal(t, 0, buf.Total())
|
||||||
|
|
||||||
|
lines, total, runID := buf.LinesSince(0)
|
||||||
|
assert.Nil(t, lines)
|
||||||
|
assert.Equal(t, 0, total)
|
||||||
|
assert.Equal(t, 1, runID)
|
||||||
|
|
||||||
|
buf.Append("after")
|
||||||
|
lines, total, runID = buf.LinesSince(0)
|
||||||
|
assert.Equal(t, []string{"after"}, lines)
|
||||||
|
assert.Equal(t, 1, total)
|
||||||
|
assert.Equal(t, 1, runID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogBuffer_Concurrent(t *testing.T) {
|
||||||
|
buf := NewLogBuffer(100)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// 10 writers
|
||||||
|
for i := range 10 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := range 50 {
|
||||||
|
buf.Append(fmt.Sprintf("writer-%d-line-%d", id, j))
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5 readers
|
||||||
|
for range 5 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for range 100 {
|
||||||
|
buf.LinesSince(0)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
assert.Equal(t, 500, buf.Total())
|
||||||
|
}
|
||||||
232
cmd/picoclaw-launcher/internal/server/process.go
Normal file
232
cmd/picoclaw-launcher/internal/server/process.go
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gatewayLogs stores captured stdout/stderr from the gateway process launched by the launcher.
|
||||||
|
var gatewayLogs = NewLogBuffer(200)
|
||||||
|
|
||||||
|
// RegisterProcessAPI registers endpoints to start, stop and check status of the picoclaw gateway.
|
||||||
|
func RegisterProcessAPI(mux *http.ServeMux, absPath string) {
|
||||||
|
mux.HandleFunc("GET /api/process/status", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleStatusGateway(w, r, absPath)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /api/process/start", handleStartGateway)
|
||||||
|
mux.HandleFunc("POST /api/process/stop", handleStopGateway)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStartGateway(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Locate picoclaw executable:
|
||||||
|
// 1. Try same directory as current executable
|
||||||
|
// 2. Fallback to just "picoclaw" (relies on $PATH)
|
||||||
|
execPath := "picoclaw"
|
||||||
|
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
dir := filepath.Dir(exe)
|
||||||
|
candidate := filepath.Join(dir, "picoclaw")
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
candidate += ".exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||||
|
execPath = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(execPath, "gateway")
|
||||||
|
|
||||||
|
stdoutPipe, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create stdout pipe: %v\n", err)
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stderrPipe, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create stderr pipe: %v\n", err)
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear old logs and increment runID before starting
|
||||||
|
gatewayLogs.Reset()
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
log.Printf("Failed to start picoclaw gateway: %v\n", err)
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read stdout and stderr into the log buffer
|
||||||
|
go scanPipe(stdoutPipe, gatewayLogs)
|
||||||
|
go scanPipe(stderrPipe, gatewayLogs)
|
||||||
|
|
||||||
|
// Wait for the process to exit in the background to avoid zombies
|
||||||
|
go func() {
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
log.Printf("Gateway process exited: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("Started picoclaw gateway (PID: %d) from %s\n", cmd.Process.Pid, execPath)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": "ok",
|
||||||
|
"pid": cmd.Process.Pid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanPipe reads lines from r and appends them to buf. It returns when r reaches EOF.
|
||||||
|
func scanPipe(r io.Reader, buf *LogBuffer) {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // up to 1MB per line
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
buf.Append(scanner.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStopGateway(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var err error
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// Kill via taskkill finding picoclaw.exe (though it might kill this config tool if it's named picoclaw-launcher.exe...? No, /IM does exact match usually, but just to be safe let's stop exactly picoclaw.exe)
|
||||||
|
// Alternatively, we use powershell to kill processes with commandline containing 'gateway'
|
||||||
|
psCmd := `Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -match 'picoclaw.*gateway' } | ForEach-Object { Stop-Process $_.ProcessId -Force }`
|
||||||
|
err = exec.Command("powershell", "-Command", psCmd).Run()
|
||||||
|
} else {
|
||||||
|
// Linux/macOS
|
||||||
|
err = exec.Command("pkill", "-f", "picoclaw gateway").Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to stop gateway (perhaps not running?): %v\n", err)
|
||||||
|
// We still return 200 OK because pkill returns an error if no process was found
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": "ok", // or "not_found"
|
||||||
|
"msg": "Stop command executed, but returned error (process might not be running).",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Stopped picoclaw gateway processes.\n")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStatusGateway(w http.ResponseWriter, r *http.Request, absPath string) {
|
||||||
|
cfg, cfgErr := config.LoadConfig(absPath)
|
||||||
|
host := "127.0.0.1"
|
||||||
|
port := 18790
|
||||||
|
if cfgErr == nil && cfg != nil {
|
||||||
|
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
|
||||||
|
host = cfg.Gateway.Host
|
||||||
|
}
|
||||||
|
if cfg.Gateway.Port != 0 {
|
||||||
|
port = cfg.Gateway.Port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||||
|
client := http.Client{Timeout: 2 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
|
||||||
|
// Build the response data map
|
||||||
|
data := map[string]any{}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
data["process_status"] = "stopped"
|
||||||
|
data["error"] = err.Error()
|
||||||
|
} else {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
data["process_status"] = "error"
|
||||||
|
data["status_code"] = resp.StatusCode
|
||||||
|
} else {
|
||||||
|
var healthData map[string]any
|
||||||
|
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
|
||||||
|
data["process_status"] = "error"
|
||||||
|
data["error"] = "invalid response from gateway"
|
||||||
|
} else {
|
||||||
|
// Gateway is running and responded properly — merge health data
|
||||||
|
for k, v := range healthData {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
data["process_status"] = "running"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append log data from the buffer
|
||||||
|
appendLogData(r, data)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendLogData reads log_offset and log_run_id query params from the request and
|
||||||
|
// populates the response data map with incremental log lines.
|
||||||
|
func appendLogData(r *http.Request, data map[string]any) {
|
||||||
|
clientOffset := 0
|
||||||
|
clientRunID := -1
|
||||||
|
|
||||||
|
if v := r.URL.Query().Get("log_offset"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
clientOffset = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := r.URL.Query().Get("log_run_id"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
clientRunID = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runID := gatewayLogs.RunID()
|
||||||
|
|
||||||
|
// If runID is 0 (never reset = never launched from this launcher), report no source
|
||||||
|
if runID == 0 {
|
||||||
|
data["logs"] = []string{}
|
||||||
|
data["log_total"] = 0
|
||||||
|
data["log_run_id"] = 0
|
||||||
|
data["log_source"] = "none"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the client's runID doesn't match, send all buffered lines (gateway restarted)
|
||||||
|
offset := clientOffset
|
||||||
|
if clientRunID != runID {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
lines, total, runID := gatewayLogs.LinesSince(offset)
|
||||||
|
if lines == nil {
|
||||||
|
lines = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
data["logs"] = lines
|
||||||
|
data["log_total"] = total
|
||||||
|
data["log_run_id"] = runID
|
||||||
|
data["log_source"] = "launcher"
|
||||||
|
}
|
||||||
196
cmd/picoclaw-launcher/internal/server/server.go
Normal file
196
cmd/picoclaw-launcher/internal/server/server.go
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultPort = "18800"
|
||||||
|
|
||||||
|
// providerStatus represents the auth status of a single provider in API responses.
|
||||||
|
type providerStatus struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
AuthMethod string `json:"auth_method"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AccountID string `json:"account_id,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Route registration ───────────────────────────────────────────
|
||||||
|
|
||||||
|
func RegisterConfigAPI(mux *http.ServeMux, absPath string) {
|
||||||
|
// GET /api/config — read config
|
||||||
|
mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg, err := config.LoadConfig(absPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
resp := map[string]any{
|
||||||
|
"config": cfg,
|
||||||
|
"path": absPath,
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(resp); err != nil {
|
||||||
|
log.Printf("Failed to encode response: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// PUT /api/config — save config
|
||||||
|
mux.HandleFunc("PUT /api/config", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
var cfg config.Config
|
||||||
|
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.SaveConfig(absPath, &cfg); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterAuthAPI(mux *http.ServeMux, absPath string) {
|
||||||
|
// GET /api/auth/status — all authenticated providers + pending login state
|
||||||
|
mux.HandleFunc("GET /api/auth/status", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
store, err := auth.LoadStore()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to load auth store: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := []providerStatus{}
|
||||||
|
for name, cred := range store.Credentials {
|
||||||
|
status := "active"
|
||||||
|
if cred.IsExpired() {
|
||||||
|
status = "expired"
|
||||||
|
} else if cred.NeedsRefresh() {
|
||||||
|
status = "needs_refresh"
|
||||||
|
}
|
||||||
|
ps := providerStatus{
|
||||||
|
Provider: name,
|
||||||
|
AuthMethod: cred.AuthMethod,
|
||||||
|
Status: status,
|
||||||
|
AccountID: cred.AccountID,
|
||||||
|
Email: cred.Email,
|
||||||
|
ProjectID: cred.ProjectID,
|
||||||
|
}
|
||||||
|
if !cred.ExpiresAt.IsZero() {
|
||||||
|
ps.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
result = append(result, ps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include pending device code state
|
||||||
|
var pendingDevice map[string]any
|
||||||
|
activeDeviceSessionMu.Lock()
|
||||||
|
if activeDeviceSession != nil {
|
||||||
|
activeDeviceSession.mu.Lock()
|
||||||
|
pendingDevice = map[string]any{
|
||||||
|
"provider": activeDeviceSession.Provider,
|
||||||
|
"status": activeDeviceSession.Status,
|
||||||
|
"device_url": activeDeviceSession.Info.VerifyURL,
|
||||||
|
"user_code": activeDeviceSession.Info.UserCode,
|
||||||
|
}
|
||||||
|
if activeDeviceSession.Error != "" {
|
||||||
|
pendingDevice["error"] = activeDeviceSession.Error
|
||||||
|
}
|
||||||
|
if activeDeviceSession.Done {
|
||||||
|
activeDeviceSession.mu.Unlock()
|
||||||
|
activeDeviceSession = nil
|
||||||
|
} else {
|
||||||
|
activeDeviceSession.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeDeviceSessionMu.Unlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"providers": result,
|
||||||
|
"pending_device": pendingDevice,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/auth/login — initiate provider login
|
||||||
|
mux.HandleFunc("POST /api/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Provider {
|
||||||
|
case "openai":
|
||||||
|
handleOpenAILogin(w, absPath)
|
||||||
|
case "anthropic":
|
||||||
|
handleAnthropicLogin(w, req.Token, absPath)
|
||||||
|
case "google-antigravity", "antigravity":
|
||||||
|
handleGoogleAntigravityLogin(w, r, absPath)
|
||||||
|
default:
|
||||||
|
http.Error(
|
||||||
|
w,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Unsupported provider: %s (supported: openai, anthropic, google-antigravity)",
|
||||||
|
req.Provider,
|
||||||
|
),
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/auth/logout — logout a provider
|
||||||
|
mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Provider == "" {
|
||||||
|
if err := auth.DeleteAllCredentials(); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearAllAuthMethodsInConfig(absPath)
|
||||||
|
} else {
|
||||||
|
if err := auth.DeleteCredential(req.Provider); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearAuthMethodInConfig(absPath, req.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /auth/callback — OAuth browser callback for Google Antigravity
|
||||||
|
mux.HandleFunc("GET /auth/callback", handleOAuthCallback)
|
||||||
|
}
|
||||||
247
cmd/picoclaw-launcher/internal/server/server_test.go
Normal file
247
cmd/picoclaw-launcher/internal/server/server_test.go
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Config API tests ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
func setupConfigMux(t *testing.T, cfg *config.Config) (*http.ServeMux, string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.json")
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal config: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterConfigAPI(mux, path)
|
||||||
|
RegisterAuthAPI(mux, path)
|
||||||
|
return mux, path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConfig(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mux, path := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/config", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Config config.Config `json:"config"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Path != path {
|
||||||
|
t.Errorf("expected path %q, got %q", path, resp.Path)
|
||||||
|
}
|
||||||
|
if len(resp.Config.ModelList) != 1 {
|
||||||
|
t.Errorf("expected 1 model, got %d", len(resp.Config.ModelList))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConfig_MissingFile_ReturnsDefault(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterConfigAPI(mux, "/tmp/nonexistent-picoclaw-launcher-test/config.json")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/config", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// LoadConfig returns a default empty config when file is missing
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 for missing file (default config), got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutConfig(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, path := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
newCfg := config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(newCfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, err := config.LoadConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load saved config: %v", err)
|
||||||
|
}
|
||||||
|
if len(saved.ModelList) != 1 {
|
||||||
|
t.Fatalf("expected 1 model saved, got %d", len(saved.ModelList))
|
||||||
|
}
|
||||||
|
if saved.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||||
|
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", saved.ModelList[0].Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutConfig_InvalidJSON(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader("{invalid"))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for invalid JSON, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auth API tests ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAuthStatus(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/auth/status", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /api/auth/status: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Providers []providerStatus `json:"providers"`
|
||||||
|
PendingDevice map[string]any `json:"pending_device"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// providers should be a non-nil list (could be empty)
|
||||||
|
if resp.Providers == nil {
|
||||||
|
t.Error("providers should not be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogin_UnsupportedProvider(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
body := `{"provider": "unsupported"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for unsupported provider, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogin_AnthropicNoToken(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
body := `{"provider": "anthropic"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for anthropic without token, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogin_InvalidBody(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader("{bad"))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for invalid JSON body, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogout_InvalidBody(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/logout", strings.NewReader("{bad"))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for invalid body, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOAuthCallback_InvalidState(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/auth/callback?state=invalid&code=test", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for invalid state, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utility tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestDefaultConfigPath(t *testing.T) {
|
||||||
|
path := DefaultConfigPath()
|
||||||
|
if path == "" {
|
||||||
|
t.Error("defaultConfigPath should not return empty")
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(path, filepath.Join(".picoclaw", "config.json")) {
|
||||||
|
t.Errorf("expected path ending with .picoclaw/config.json, got %q", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLocalIP(t *testing.T) {
|
||||||
|
// Just ensure it doesn't panic; IP may or may not be available
|
||||||
|
ip := GetLocalIP()
|
||||||
|
if ip != "" {
|
||||||
|
// If returned, should look like an IP
|
||||||
|
if !strings.Contains(ip, ".") {
|
||||||
|
t.Errorf("getLocalIP returned non-IPv4 looking string: %q", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
cmd/picoclaw-launcher/internal/server/utils.go
Normal file
28
cmd/picoclaw-launcher/internal/server/utils.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DefaultConfigPath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "config.json"
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".picoclaw", "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLocalIP() string {
|
||||||
|
addrs, err := net.InterfaceAddrs()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, a := range addrs {
|
||||||
|
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
|
||||||
|
return ipnet.IP.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
1997
cmd/picoclaw-launcher/internal/ui/index.html
Normal file
1997
cmd/picoclaw-launcher/internal/ui/index.html
Normal file
File diff suppressed because it is too large
Load diff
127
cmd/picoclaw-launcher/main.go
Normal file
127
cmd/picoclaw-launcher/main.go
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
// PicoClaw Launcher - Standalone HTTP service
|
||||||
|
//
|
||||||
|
// Provides a web-based JSON editor for picoclaw config files,
|
||||||
|
// with OAuth provider authentication support.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||||
|
// ./picoclaw-launcher [config.json]
|
||||||
|
// ./picoclaw-launcher -public config.json
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed internal/ui/index.html
|
||||||
|
var staticFiles embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
|
||||||
|
fmt.Fprintf(os.Stderr, "Arguments:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Options:\n")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
|
||||||
|
fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
|
||||||
|
fmt.Fprintf(
|
||||||
|
os.Stderr,
|
||||||
|
" %s -public ./config.json Allow access from other devices on the network\n",
|
||||||
|
os.Args[0],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
configPath := server.DefaultConfigPath()
|
||||||
|
if flag.NArg() > 0 {
|
||||||
|
configPath = flag.Arg(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := filepath.Abs(configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to resolve config path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var addr string
|
||||||
|
if *public {
|
||||||
|
addr = "0.0.0.0:" + server.DefaultPort
|
||||||
|
} else {
|
||||||
|
addr = "127.0.0.1:" + server.DefaultPort
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
server.RegisterConfigAPI(mux, absPath)
|
||||||
|
server.RegisterAuthAPI(mux, absPath)
|
||||||
|
server.RegisterProcessAPI(mux, absPath)
|
||||||
|
|
||||||
|
staticFS, err := fs.Sub(staticFiles, "internal/ui")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create sub filesystem: %v", err)
|
||||||
|
}
|
||||||
|
mux.Handle("/", http.FileServer(http.FS(staticFS)))
|
||||||
|
|
||||||
|
// Print startup banner
|
||||||
|
fmt.Println("=============================================")
|
||||||
|
fmt.Println(" PicoClaw Launcher")
|
||||||
|
fmt.Println("=============================================")
|
||||||
|
fmt.Printf(" Config file : %s\n", absPath)
|
||||||
|
fmt.Printf(" Listen addr : %s\n\n", addr)
|
||||||
|
fmt.Println(" Open the following URL in your browser")
|
||||||
|
fmt.Println(" to view and edit the configuration:")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf(" >> http://localhost:%s <<\n", server.DefaultPort)
|
||||||
|
if *public {
|
||||||
|
if ip := server.GetLocalIP(); ip != "" {
|
||||||
|
fmt.Printf(" >> http://%s:%s <<\n", ip, server.DefaultPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
// fmt.Println("=============================================")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// Wait briefly to ensure the server is ready before opening the browser
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
url := "http://localhost:" + server.DefaultPort
|
||||||
|
if err := openBrowser(url); err != nil {
|
||||||
|
log.Printf("Warning: Failed to auto-open browser: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||||
|
log.Fatalf("Server failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openBrowser automatically opens the given URL in the default browser.
|
||||||
|
func openBrowser(url string) error {
|
||||||
|
var err error
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
err = exec.Command("xdg-open", url).Start()
|
||||||
|
case "windows":
|
||||||
|
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||||
|
case "darwin":
|
||||||
|
err = exec.Command("open", url).Start()
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unsupported platform")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
22
cmd/picoclaw-launcher/winres/winres.json
Normal file
22
cmd/picoclaw-launcher/winres/winres.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"RT_GROUP_ICON": {
|
||||||
|
"APP": {
|
||||||
|
"0000": "../icon.ico"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RT_MANIFEST": {
|
||||||
|
"#1": {
|
||||||
|
"0409": {
|
||||||
|
"identity": {
|
||||||
|
"name": "PicoClaw Launcher",
|
||||||
|
"version": "0.0.0.0"
|
||||||
|
},
|
||||||
|
"description": "PicoClaw Launcher - Web-based configuration editor",
|
||||||
|
"minimum-os": "win7",
|
||||||
|
"execution-level": "asInvoker",
|
||||||
|
"dpi-awareness": "system",
|
||||||
|
"use-common-controls-v6": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,7 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
|
defer msgBus.Close()
|
||||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
// Print agent startup info (only for interactive mode)
|
// Print agent startup info (only for interactive mode)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -15,14 +16,17 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
const (
|
||||||
|
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
||||||
|
defaultAnthropicModel = "claude-sonnet-4.6"
|
||||||
|
)
|
||||||
|
|
||||||
func authLoginCmd(provider string, useDeviceCode bool) error {
|
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "openai":
|
case "openai":
|
||||||
return authLoginOpenAI(useDeviceCode)
|
return authLoginOpenAI(useDeviceCode)
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
return authLoginPasteToken(provider)
|
return authLoginAnthropic(useOauth)
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
return authLoginGoogleAntigravity()
|
return authLoginGoogleAntigravity()
|
||||||
default:
|
default:
|
||||||
|
|
@ -163,6 +167,81 @@ func authLoginGoogleAntigravity() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authLoginAnthropic(useOauth bool) error {
|
||||||
|
if useOauth {
|
||||||
|
return authLoginAnthropicSetupToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Anthropic login method:")
|
||||||
|
fmt.Println(" 1) Setup token (from `claude setup-token`) (Recommended)")
|
||||||
|
fmt.Println(" 2) API key (from console.anthropic.com)")
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
for {
|
||||||
|
fmt.Print("Choose [1]: ")
|
||||||
|
choice := "1"
|
||||||
|
if scanner.Scan() {
|
||||||
|
text := strings.TrimSpace(scanner.Text())
|
||||||
|
if text != "" {
|
||||||
|
choice = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch choice {
|
||||||
|
case "1":
|
||||||
|
return authLoginAnthropicSetupToken()
|
||||||
|
case "2":
|
||||||
|
return authLoginPasteToken("anthropic")
|
||||||
|
default:
|
||||||
|
fmt.Printf("Invalid choice: %s. Please enter 1 or 2.\n", choice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authLoginAnthropicSetupToken() error {
|
||||||
|
cred, err := auth.LoginSetupToken(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("login failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = auth.SetCredential("anthropic", cred); err != nil {
|
||||||
|
return fmt.Errorf("failed to save credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
appCfg, err := internal.LoadConfig()
|
||||||
|
if err == nil {
|
||||||
|
appCfg.Providers.Anthropic.AuthMethod = "oauth"
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i := range appCfg.ModelList {
|
||||||
|
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||||
|
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: defaultAnthropicModel,
|
||||||
|
Model: "anthropic/" + defaultAnthropicModel,
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
})
|
||||||
|
// Only set default model if user has no default configured yet
|
||||||
|
if appCfg.Agents.Defaults.GetModelName() == "" {
|
||||||
|
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||||
|
return fmt.Errorf("could not update config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Setup token saved for Anthropic!")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -177,7 +256,10 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading userinfo response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -217,13 +299,12 @@ func authLoginPasteToken(provider string) error {
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
ModelName: "claude-sonnet-4.6",
|
ModelName: defaultAnthropicModel,
|
||||||
Model: "anthropic/claude-sonnet-4.6",
|
Model: "anthropic/" + defaultAnthropicModel,
|
||||||
AuthMethod: "token",
|
AuthMethod: "token",
|
||||||
})
|
})
|
||||||
|
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
|
||||||
}
|
}
|
||||||
// Update default model
|
|
||||||
appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
|
||||||
case "openai":
|
case "openai":
|
||||||
appCfg.Providers.OpenAI.AuthMethod = "token"
|
appCfg.Providers.OpenAI.AuthMethod = "token"
|
||||||
// Update ModelList
|
// Update ModelList
|
||||||
|
|
@ -360,6 +441,16 @@ func authStatusCmd() error {
|
||||||
if !cred.ExpiresAt.IsZero() {
|
if !cred.ExpiresAt.IsZero() {
|
||||||
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if provider == "anthropic" && cred.AuthMethod == "oauth" {
|
||||||
|
usage, err := auth.FetchAnthropicUsage(cred.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" Usage: unavailable (%v)\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" Usage (5h): %.1f%%\n", usage.FiveHourUtilization*100)
|
||||||
|
fmt.Printf(" Usage (7d): %.1f%%\n", usage.SevenDayUtilization*100)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ func newLoginCommand() *cobra.Command {
|
||||||
var (
|
var (
|
||||||
provider string
|
provider string
|
||||||
useDeviceCode bool
|
useDeviceCode bool
|
||||||
|
useOauth bool
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|
@ -13,12 +14,16 @@ func newLoginCommand() *cobra.Command {
|
||||||
Short: "Login via OAuth or paste token",
|
Short: "Login via OAuth or paste token",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
return authLoginCmd(provider, useDeviceCode)
|
return authLoginCmd(provider, useDeviceCode, useOauth)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
|
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
|
||||||
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
||||||
|
cmd.Flags().BoolVar(
|
||||||
|
&useOauth, "setup-token", false,
|
||||||
|
"Use setup-token flow for Anthropic (from `claude setup-token`)",
|
||||||
|
)
|
||||||
_ = cmd.MarkFlagRequired("provider")
|
_ = cmd.MarkFlagRequired("provider")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,37 @@ package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/agent"
|
"github.com/sipeed/picoclaw/pkg/agent"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/cron"
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
"github.com/sipeed/picoclaw/pkg/devices"
|
"github.com/sipeed/picoclaw/pkg/devices"
|
||||||
"github.com/sipeed/picoclaw/pkg/health"
|
"github.com/sipeed/picoclaw/pkg/health"
|
||||||
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
|
@ -105,48 +117,28 @@ func gatewayCmd(debug bool) error {
|
||||||
return tools.SilentResult(response)
|
return tools.SilentResult(response)
|
||||||
})
|
})
|
||||||
|
|
||||||
channelManager, err := channels.NewManager(cfg, msgBus)
|
// Create media store for file lifecycle management with TTL cleanup
|
||||||
|
mediaStore := media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
|
||||||
|
Enabled: cfg.Tools.MediaCleanup.Enabled,
|
||||||
|
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
|
||||||
|
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
|
||||||
|
})
|
||||||
|
mediaStore.Start()
|
||||||
|
|
||||||
|
channelManager, err := channels.NewManager(cfg, msgBus, mediaStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
mediaStore.Stop()
|
||||||
return fmt.Errorf("error creating channel manager: %w", err)
|
return fmt.Errorf("error creating channel manager: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject channel manager into agent loop for command handling
|
// Inject channel manager and media store into agent loop
|
||||||
agentLoop.SetChannelManager(channelManager)
|
agentLoop.SetChannelManager(channelManager)
|
||||||
|
agentLoop.SetMediaStore(mediaStore)
|
||||||
|
|
||||||
var transcriber *voice.GroqTranscriber
|
// Wire up voice transcription if a supported provider is configured.
|
||||||
groqAPIKey := cfg.Providers.Groq.APIKey
|
if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
|
||||||
if groqAPIKey == "" {
|
agentLoop.SetTranscriber(transcriber)
|
||||||
for _, mc := range cfg.ModelList {
|
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
|
||||||
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
|
|
||||||
groqAPIKey = mc.APIKey
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if groqAPIKey != "" {
|
|
||||||
transcriber = voice.NewGroqTranscriber(groqAPIKey)
|
|
||||||
logger.InfoC("voice", "Groq voice transcription enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
if transcriber != nil {
|
|
||||||
if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
|
|
||||||
if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
|
|
||||||
tc.SetTranscriber(transcriber)
|
|
||||||
logger.InfoC("voice", "Groq transcription attached to Telegram channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if discordChannel, ok := channelManager.GetChannel("discord"); ok {
|
|
||||||
if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
|
|
||||||
dc.SetTranscriber(transcriber)
|
|
||||||
logger.InfoC("voice", "Groq transcription attached to Discord channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if slackChannel, ok := channelManager.GetChannel("slack"); ok {
|
|
||||||
if sc, ok := slackChannel.(*channels.SlackChannel); ok {
|
|
||||||
sc.SetTranscriber(transcriber)
|
|
||||||
logger.InfoC("voice", "Groq transcription attached to Slack channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enabledChannels := channelManager.GetEnabledChannels()
|
enabledChannels := channelManager.GetEnabledChannels()
|
||||||
|
|
@ -184,16 +176,16 @@ func gatewayCmd(debug bool) error {
|
||||||
fmt.Println("✓ Device event service started")
|
fmt.Println("✓ Device event service started")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup shared HTTP server with health endpoints and webhook handlers
|
||||||
|
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
channelManager.SetupHTTPServer(addr, healthServer)
|
||||||
|
|
||||||
if err := channelManager.StartAll(ctx); err != nil {
|
if err := channelManager.StartAll(ctx); err != nil {
|
||||||
fmt.Printf("Error starting channels: %v\n", err)
|
fmt.Printf("Error starting channels: %v\n", err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
|
||||||
go func() {
|
|
||||||
if err := healthServer.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
|
||||||
go agentLoop.Run(ctx)
|
go agentLoop.Run(ctx)
|
||||||
|
|
@ -207,12 +199,19 @@ func gatewayCmd(debug bool) error {
|
||||||
cp.Close()
|
cp.Close()
|
||||||
}
|
}
|
||||||
cancel()
|
cancel()
|
||||||
healthServer.Stop(context.Background())
|
msgBus.Close()
|
||||||
|
|
||||||
|
// Use a fresh context with timeout for graceful shutdown,
|
||||||
|
// since the original ctx is already canceled.
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
channelManager.StopAll(shutdownCtx)
|
||||||
deviceService.Stop()
|
deviceService.Stop()
|
||||||
heartbeatService.Stop()
|
heartbeatService.Stop()
|
||||||
cronService.Stop()
|
cronService.Stop()
|
||||||
|
mediaStore.Stop()
|
||||||
agentLoop.Stop()
|
agentLoop.Stop()
|
||||||
channelManager.StopAll(ctx)
|
|
||||||
fmt.Println("✓ Gateway stopped")
|
fmt.Println("✓ Gateway stopped")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -231,15 +230,25 @@ func setupCronTool(
|
||||||
// Create cron service
|
// Create cron service
|
||||||
cronService := cron.NewCronService(cronStorePath, nil)
|
cronService := cron.NewCronService(cronStorePath, nil)
|
||||||
|
|
||||||
// Create and register CronTool
|
// Create and register CronTool if enabled
|
||||||
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
var cronTool *tools.CronTool
|
||||||
agentLoop.RegisterTool(cronTool)
|
if cfg.Tools.IsToolEnabled("cron") {
|
||||||
|
var err error
|
||||||
|
cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Critical error during CronTool initialization: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Set the onJob handler
|
agentLoop.RegisterTool(cronTool)
|
||||||
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
}
|
||||||
result := cronTool.ExecuteJob(context.Background(), job)
|
|
||||||
return result, nil
|
// Set onJob handler
|
||||||
})
|
if cronTool != nil {
|
||||||
|
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
||||||
|
result := cronTool.ExecuteJob(context.Background(), job)
|
||||||
|
return result, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return cronService
|
return cronService
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,21 @@ var (
|
||||||
goVersion string
|
goVersion string
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetConfigPath() string {
|
// GetPicoclawHome returns the picoclaw home directory.
|
||||||
|
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||||
|
func GetPicoclawHome() string {
|
||||||
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
return filepath.Join(home, ".picoclaw", "config.json")
|
return filepath.Join(home, ".picoclaw")
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetConfigPath() string {
|
||||||
|
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
|
return filepath.Join(GetPicoclawHome(), "config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() (*config.Config, error) {
|
func LoadConfig() (*config.Config, error) {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,27 @@ func TestGetConfigPath(t *testing.T) {
|
||||||
assert.Equal(t, want, got)
|
assert.Equal(t, want, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
|
||||||
|
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
|
||||||
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
|
got := GetConfigPath()
|
||||||
|
want := filepath.Join("/custom/picoclaw", "config.json")
|
||||||
|
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
|
||||||
|
t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
|
||||||
|
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
|
||||||
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
|
got := GetConfigPath()
|
||||||
|
want := "/custom/config.json"
|
||||||
|
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
||||||
oldVersion, oldGit := version, gitCommit
|
oldVersion, oldGit := version, gitCommit
|
||||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
||||||
|
|
@ -95,3 +116,13 @@ func TestGetConfigPath_Windows(t *testing.T) {
|
||||||
func TestGetVersion(t *testing.T) {
|
func TestGetVersion(t *testing.T) {
|
||||||
assert.Equal(t, "dev", GetVersion())
|
assert.Equal(t, "dev", GetVersion())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetConfigPath_WithEnv(t *testing.T) {
|
||||||
|
t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json")
|
||||||
|
t.Setenv("HOME", "/tmp/home") // Also set home to ensure env is preferred
|
||||||
|
|
||||||
|
got := GetConfigPath()
|
||||||
|
want := "/tmp/custom/config.json"
|
||||||
|
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,19 +11,21 @@ func NewMigrateCommand() *cobra.Command {
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "migrate",
|
Use: "migrate",
|
||||||
Short: "Migrate from OpenClaw to PicoClaw",
|
Short: "Migrate from xxxclaw(openclaw, etc.) to picoclaw",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
Example: ` picoclaw migrate
|
Example: ` picoclaw migrate
|
||||||
|
picoclaw migrate --from openclaw
|
||||||
picoclaw migrate --dry-run
|
picoclaw migrate --dry-run
|
||||||
picoclaw migrate --refresh
|
picoclaw migrate --refresh
|
||||||
picoclaw migrate --force`,
|
picoclaw migrate --force`,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
result, err := migrate.Run(opts)
|
m := migrate.NewMigrateInstance(opts)
|
||||||
|
result, err := m.Run(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !opts.DryRun {
|
if !opts.DryRun {
|
||||||
migrate.PrintSummary(result)
|
m.PrintSummary(result)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|
@ -31,6 +33,8 @@ func NewMigrateCommand() *cobra.Command {
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
||||||
"Show what would be migrated without making changes")
|
"Show what would be migrated without making changes")
|
||||||
|
cmd.Flags().StringVar(&opts.Source, "from", "openclaw",
|
||||||
|
"Source to migrate from (e.g., openclaw)")
|
||||||
cmd.Flags().BoolVar(&opts.Refresh, "refresh", false,
|
cmd.Flags().BoolVar(&opts.Refresh, "refresh", false,
|
||||||
"Re-sync workspace files from OpenClaw (repeatable)")
|
"Re-sync workspace files from OpenClaw (repeatable)")
|
||||||
cmd.Flags().BoolVar(&opts.ConfigOnly, "config-only", false,
|
cmd.Flags().BoolVar(&opts.ConfigOnly, "config-only", false,
|
||||||
|
|
@ -39,10 +43,10 @@ func NewMigrateCommand() *cobra.Command {
|
||||||
"Only migrate workspace files, skip config")
|
"Only migrate workspace files, skip config")
|
||||||
cmd.Flags().BoolVar(&opts.Force, "force", false,
|
cmd.Flags().BoolVar(&opts.Force, "force", false,
|
||||||
"Skip confirmation prompts")
|
"Skip confirmation prompts")
|
||||||
cmd.Flags().StringVar(&opts.OpenClawHome, "openclaw-home", "",
|
cmd.Flags().StringVar(&opts.SourceHome, "source-home", "",
|
||||||
"Override OpenClaw home directory (default: ~/.openclaw)")
|
"Override source home directory (default: ~/.openclaw)")
|
||||||
cmd.Flags().StringVar(&opts.PicoClawHome, "picoclaw-home", "",
|
cmd.Flags().StringVar(&opts.TargetHome, "target-home", "",
|
||||||
"Override PicoClaw home directory (default: ~/.picoclaw)")
|
"Override target home directory (default: ~/.picoclaw)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ func TestNewMigrateCommand(t *testing.T) {
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
assert.Equal(t, "migrate", cmd.Use)
|
assert.Equal(t, "migrate", cmd.Use)
|
||||||
assert.Equal(t, "Migrate from OpenClaw to PicoClaw", cmd.Short)
|
assert.Equal(t, "Migrate from xxxclaw(openclaw, etc.) to picoclaw", cmd.Short)
|
||||||
|
|
||||||
assert.Len(t, cmd.Aliases, 0)
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
|
|
||||||
|
|
@ -33,6 +33,6 @@ func TestNewMigrateCommand(t *testing.T) {
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("config-only"))
|
assert.NotNil(t, cmd.Flags().Lookup("config-only"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("workspace-only"))
|
assert.NotNil(t, cmd.Flags().Lookup("workspace-only"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("openclaw-home"))
|
assert.NotNil(t, cmd.Flags().Lookup("source-home"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("picoclaw-home"))
|
assert.NotNil(t, cmd.Flags().Lookup("target-home"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
cmd/picoclaw/internal/onboard/helpers_test.go
Normal file
25
cmd/picoclaw/internal/onboard/helpers_test.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package onboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCopyEmbeddedToTargetUsesAgentsMarkdown(t *testing.T) {
|
||||||
|
targetDir := t.TempDir()
|
||||||
|
|
||||||
|
if err := copyEmbeddedToTarget(targetDir); err != nil {
|
||||||
|
t.Fatalf("copyEmbeddedToTarget() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
agentsPath := filepath.Join(targetDir, "AGENTS.md")
|
||||||
|
if _, err := os.Stat(agentsPath); err != nil {
|
||||||
|
t.Fatalf("expected %s to exist: %v", agentsPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
legacyPath := filepath.Join(targetDir, "AGENT.md")
|
||||||
|
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -71,7 +71,7 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
newInstallBuiltinCommand(workspaceFn),
|
newInstallBuiltinCommand(workspaceFn),
|
||||||
newListBuiltinCommand(),
|
newListBuiltinCommand(),
|
||||||
newRemoveCommand(installerFn),
|
newRemoveCommand(installerFn),
|
||||||
newSearchCommand(installerFn),
|
newSearchCommand(),
|
||||||
newShowCommand(loaderFn),
|
newShowCommand(loaderFn),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const skillsSearchMaxResults = 20
|
||||||
|
|
||||||
func skillsListCmd(loader *skills.SkillsLoader) {
|
func skillsListCmd(loader *skills.SkillsLoader) {
|
||||||
allSkills := loader.ListSkills()
|
allSkills := loader.ListSkills()
|
||||||
|
|
||||||
|
|
@ -215,34 +217,43 @@ func skillsListBuiltinCmd() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsSearchCmd(installer *skills.SkillInstaller) {
|
func skillsSearchCmd(query string) {
|
||||||
fmt.Println("Searching for available skills...")
|
fmt.Println("Searching for available skills...")
|
||||||
|
|
||||||
|
cfg, err := internal.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("✗ Failed to load config: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||||
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
|
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
||||||
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
availableSkills, err := installer.ListAvailableSkills(ctx)
|
results, err := registryMgr.SearchAll(ctx, query, skillsSearchMaxResults)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
|
fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(availableSkills) == 0 {
|
if len(results) == 0 {
|
||||||
fmt.Println("No skills available.")
|
fmt.Println("No skills available.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills))
|
fmt.Printf("\nAvailable Skills (%d):\n", len(results))
|
||||||
fmt.Println("--------------------")
|
fmt.Println("--------------------")
|
||||||
for _, skill := range availableSkills {
|
for _, result := range results {
|
||||||
fmt.Printf(" 📦 %s\n", skill.Name)
|
fmt.Printf(" 📦 %s\n", result.DisplayName)
|
||||||
fmt.Printf(" %s\n", skill.Description)
|
fmt.Printf(" %s\n", result.Summary)
|
||||||
fmt.Printf(" Repo: %s\n", skill.Repository)
|
fmt.Printf(" Slug: %s\n", result.Slug)
|
||||||
if skill.Author != "" {
|
fmt.Printf(" Registry: %s\n", result.RegistryName)
|
||||||
fmt.Printf(" Author: %s\n", skill.Author)
|
if result.Version != "" {
|
||||||
}
|
fmt.Printf(" Version: %s\n", result.Version)
|
||||||
if len(skill.Tags) > 0 {
|
|
||||||
fmt.Printf(" Tags: %v\n", skill.Tags)
|
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ picoclaw skills install --registry clawhub github
|
||||||
`,
|
`,
|
||||||
Args: func(cmd *cobra.Command, args []string) error {
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
if registry != "" {
|
if registry != "" {
|
||||||
if len(args) != 2 {
|
if len(args) != 1 {
|
||||||
return fmt.Errorf("when --registry is set, exactly 2 arguments are required: <name> <slug>")
|
return fmt.Errorf("when --registry is set, exactly 1 argument is required: <slug>")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +45,7 @@ picoclaw skills install --registry clawhub github
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return skillsInstallFromRegistry(cfg, args[0], args[1])
|
return skillsInstallFromRegistry(cfg, registry, args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
return skillsInstallCmd(installer, args[0])
|
return skillsInstallCmd(installer, args[0])
|
||||||
|
|
|
||||||
|
|
@ -26,3 +26,72 @@ func TestNewInstallSubcommand(t *testing.T) {
|
||||||
|
|
||||||
assert.Len(t, cmd.Aliases, 0)
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInstallCommandArgs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
registry string
|
||||||
|
expectError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no registry, one arg",
|
||||||
|
args: []string{"sipeed/picoclaw-skills/weather"},
|
||||||
|
registry: "",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no registry, no args",
|
||||||
|
args: []string{},
|
||||||
|
registry: "",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "exactly 1 argument is required: <github>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no registry, too many args",
|
||||||
|
args: []string{"arg1", "arg2"},
|
||||||
|
registry: "",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "exactly 1 argument is required: <github>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with registry, one arg",
|
||||||
|
args: []string{"weather-skill"},
|
||||||
|
registry: "clawhub",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with registry, no args",
|
||||||
|
args: []string{},
|
||||||
|
registry: "clawhub",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "when --registry is set, exactly 1 argument is required: <slug>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with registry, too many args",
|
||||||
|
args: []string{"arg1", "arg2"},
|
||||||
|
registry: "clawhub",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "when --registry is set, exactly 1 argument is required: <slug>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cmd := newInstallCommand(nil)
|
||||||
|
|
||||||
|
if tt.registry != "" {
|
||||||
|
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cmd.Args(cmd, tt.args)
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, tt.errorMsg, err.Error())
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,19 @@ package skills
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newSearchCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
func newSearchCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "search",
|
Use: "search [query]",
|
||||||
Short: "Search available skills",
|
Short: "Search available skills",
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
Args: cobra.MaximumNArgs(1),
|
||||||
installer, err := installerFn()
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
if err != nil {
|
query := ""
|
||||||
return err
|
if len(args) == 1 {
|
||||||
|
query = args[0]
|
||||||
}
|
}
|
||||||
skillsSearchCmd(installer)
|
skillsSearchCmd(query)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewSearchSubcommand(t *testing.T) {
|
func TestNewSearchSubcommand(t *testing.T) {
|
||||||
cmd := newSearchCommand(nil)
|
cmd := newSearchCommand()
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
assert.Equal(t, "search", cmd.Use)
|
assert.Equal(t, "search [query]", cmd.Use)
|
||||||
assert.Equal(t, "Search available skills", cmd.Short)
|
assert.Equal(t, "Search available skills", cmd.Short)
|
||||||
|
|
||||||
assert.Nil(t, cmd.Run)
|
assert.Nil(t, cmd.Run)
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,21 @@ func NewPicoclawCommand() *cobra.Command {
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
colorBlue = "\033[1;38;2;62;93;185m"
|
||||||
|
colorRed = "\033[1;38;2;213;70;70m"
|
||||||
|
banner = "\r\n" +
|
||||||
|
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
||||||
|
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
||||||
|
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
|
||||||
|
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
|
||||||
|
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
||||||
|
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
|
||||||
|
"\033[0m\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
fmt.Printf("%s", banner)
|
||||||
cmd := NewPicoclawCommand()
|
cmd := NewPicoclawCommand()
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20,
|
||||||
|
"summarize_message_threshold": 20,
|
||||||
|
"summarize_token_percent": 75
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"model_list": [
|
"model_list": [
|
||||||
|
|
@ -20,7 +22,8 @@
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key",
|
"api_key": "sk-ant-your-key",
|
||||||
"api_base": "https://api.anthropic.com/v1"
|
"api_base": "https://api.anthropic.com/v1",
|
||||||
|
"thinking_level": "high"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gemini",
|
"model_name": "gemini",
|
||||||
|
|
@ -49,33 +52,44 @@
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||||
|
"base_url": "",
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
"allow_from": [
|
"allow_from": [
|
||||||
"YOUR_USER_ID"
|
"YOUR_USER_ID"
|
||||||
]
|
],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "YOUR_DISCORD_BOT_TOKEN",
|
"token": "YOUR_DISCORD_BOT_TOKEN",
|
||||||
|
"proxy": "",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"mention_only": false
|
"group_trigger": {
|
||||||
|
"mention_only": false
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"qq": {
|
"qq": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"app_id": "YOUR_QQ_APP_ID",
|
"app_id": "YOUR_QQ_APP_ID",
|
||||||
"app_secret": "YOUR_QQ_APP_SECRET",
|
"app_secret": "YOUR_QQ_APP_SECRET",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"maixcam": {
|
"maixcam": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 18790,
|
"port": 18790,
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"bridge_url": "ws://localhost:3001",
|
"bridge_url": "ws://localhost:3001",
|
||||||
"allow_from": []
|
"use_native": false,
|
||||||
|
"session_store_path": "",
|
||||||
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"feishu": {
|
"feishu": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
@ -83,28 +97,30 @@
|
||||||
"app_secret": "",
|
"app_secret": "",
|
||||||
"encrypt_key": "",
|
"encrypt_key": "",
|
||||||
"verification_token": "",
|
"verification_token": "",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"dingtalk": {
|
"dingtalk": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"client_id": "YOUR_CLIENT_ID",
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
"client_secret": "YOUR_CLIENT_SECRET",
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"slack": {
|
"slack": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
||||||
"app_token": "xapp-YOUR-APP-TOKEN",
|
"app_token": "xapp-YOUR-APP-TOKEN",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"line": {
|
"line": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18791,
|
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"onebot": {
|
"onebot": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
@ -112,33 +128,42 @@
|
||||||
"access_token": "",
|
"access_token": "",
|
||||||
"reconnect_interval": 5,
|
"reconnect_interval": 5,
|
||||||
"group_trigger_prefix": [],
|
"group_trigger_prefix": [],
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
|
"_comment": "WeCom Bot - Easier setup, supports group chats",
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18793,
|
|
||||||
"webhook_path": "/webhook/wecom",
|
"webhook_path": "/webhook/wecom",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reply_timeout": 5
|
"reply_timeout": 5,
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"wecom_app": {
|
"wecom_app": {
|
||||||
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
|
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only.",
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"corp_id": "YOUR_CORP_ID",
|
"corp_id": "YOUR_CORP_ID",
|
||||||
"corp_secret": "YOUR_CORP_SECRET",
|
"corp_secret": "YOUR_CORP_SECRET",
|
||||||
"agent_id": 1000002,
|
"agent_id": 1000002,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
"webhook_path": "/webhook/wecom-app",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reply_timeout": 5
|
"reply_timeout": 5,
|
||||||
|
"reasoning_channel_id": ""
|
||||||
|
},
|
||||||
|
"wecom_aibot": {
|
||||||
|
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
|
||||||
|
"enabled": false,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
"max_steps": 10,
|
||||||
|
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
|
||||||
|
"reasoning_channel_id": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
@ -200,43 +225,190 @@
|
||||||
"mistral": {
|
"mistral": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"api_base": "https://api.mistral.ai/v1"
|
"api_base": "https://api.mistral.ai/v1"
|
||||||
|
},
|
||||||
|
"avian": {
|
||||||
|
"api_key": "",
|
||||||
|
"api_base": "https://api.avian.io/v1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
"allow_read_paths": null,
|
||||||
|
"allow_write_paths": null,
|
||||||
"web": {
|
"web": {
|
||||||
|
"enabled": true,
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
|
"tavily": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "",
|
||||||
|
"base_url": "",
|
||||||
|
"max_results": 0
|
||||||
|
},
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"perplexity": {
|
"perplexity": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "pplx-xxx",
|
"api_key": "",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"proxy": ""
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://localhost:8888",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"glm_search": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "",
|
||||||
|
"base_url": "https://open.bigmodel.cn/api/paas/v4/web_search",
|
||||||
|
"search_engine": "search_std",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"fetch_limit_bytes": 10485760
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
|
"enabled": true,
|
||||||
"exec_timeout_minutes": 5
|
"exec_timeout_minutes": 5
|
||||||
},
|
},
|
||||||
|
"mcp": {
|
||||||
|
"enabled": false,
|
||||||
|
"servers": {
|
||||||
|
"context7": {
|
||||||
|
"enabled": false,
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://mcp.context7.com/mcp",
|
||||||
|
"headers": {
|
||||||
|
"CONTEXT7_API_KEY": "ctx7sk-xx"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filesystem": {
|
||||||
|
"enabled": false,
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-filesystem",
|
||||||
|
"/tmp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"enabled": false,
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-github"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"brave-search": {
|
||||||
|
"enabled": false,
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-brave-search"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"BRAVE_API_KEY": "YOUR_BRAVE_API_KEY"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postgres": {
|
||||||
|
"enabled": false,
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-postgres",
|
||||||
|
"postgresql://user:password@localhost/dbname"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"slack": {
|
||||||
|
"enabled": false,
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-slack"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
|
||||||
|
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"exec": {
|
"exec": {
|
||||||
"enable_deny_patterns": false,
|
"enabled": true,
|
||||||
"custom_deny_patterns": []
|
"enable_deny_patterns": true,
|
||||||
|
"custom_deny_patterns": null,
|
||||||
|
"custom_allow_patterns": null
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
|
"enabled": true,
|
||||||
"registries": {
|
"registries": {
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"base_url": "https://clawhub.ai",
|
"base_url": "https://clawhub.ai",
|
||||||
"search_path": "/api/v1/search",
|
"auth_token": "",
|
||||||
"skills_path": "/api/v1/skills",
|
"search_path": "",
|
||||||
"download_path": "/api/v1/download"
|
"skills_path": "",
|
||||||
|
"download_path": "",
|
||||||
|
"timeout": 0,
|
||||||
|
"max_zip_size": 0,
|
||||||
|
"max_response_size": 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"max_concurrent_searches": 2,
|
||||||
|
"search_cache": {
|
||||||
|
"max_size": 50,
|
||||||
|
"ttl_seconds": 300
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"media_cleanup": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_age_minutes": 30,
|
||||||
|
"interval_minutes": 5
|
||||||
|
},
|
||||||
|
"append_file": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"edit_file": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"find_skills": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"i2c": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"install_skill": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"list_dir": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"read_file": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"spawn": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"spi": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"subagent": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"web_fetch": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"write_file": {
|
||||||
|
"enabled": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"heartbeat": {
|
"heartbeat": {
|
||||||
|
|
|
||||||
44
docker/Dockerfile.full
Normal file
44
docker/Dockerfile.full
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# ============================================================
|
||||||
|
# Stage 1: Build the picoclaw binary
|
||||||
|
# ============================================================
|
||||||
|
FROM golang:1.26.0-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git make
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Cache dependencies
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
RUN make build
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Stage 2: Node.js-based runtime with full MCP support
|
||||||
|
# ============================================================
|
||||||
|
FROM node:24-alpine3.23
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
python3 \
|
||||||
|
py3-pip
|
||||||
|
|
||||||
|
# Install uv and symlink to system path
|
||||||
|
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||||
|
ln -s /root/.local/bin/uv /usr/local/bin/uv && \
|
||||||
|
ln -s /root/.local/bin/uvx /usr/local/bin/uvx && \
|
||||||
|
uv --version
|
||||||
|
|
||||||
|
# Copy binary
|
||||||
|
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||||
|
|
||||||
|
# Create picoclaw home directory
|
||||||
|
RUN /usr/local/bin/picoclaw onboard
|
||||||
|
|
||||||
|
ENTRYPOINT ["picoclaw"]
|
||||||
|
CMD ["gateway"]
|
||||||
|
|
@ -5,6 +5,8 @@ ARG TARGETPLATFORM
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["picoclaw"]
|
RUN chmod +x /entrypoint.sh
|
||||||
CMD ["gateway"]
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
44
docker/docker-compose.full.yml
Normal file
44
docker/docker-compose.full.yml
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
services:
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# PicoClaw Agent (one-shot query) - Full MCP Support
|
||||||
|
# docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent -m "Hello"
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
picoclaw-agent:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.full
|
||||||
|
container_name: picoclaw-agent-full
|
||||||
|
profiles:
|
||||||
|
- agent
|
||||||
|
volumes:
|
||||||
|
- ../config/config.json:/root/.picoclaw/config.json:ro
|
||||||
|
- picoclaw-workspace:/root/.picoclaw/workspace
|
||||||
|
- picoclaw-npm-cache:/root/.npm # npm cache for faster MCP server installs
|
||||||
|
entrypoint: ["picoclaw", "agent"]
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# PicoClaw Gateway (Long-running Bot) - Full MCP Support
|
||||||
|
# docker compose -f docker/docker-compose.full.yml --profile gateway up
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
picoclaw-gateway:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.full
|
||||||
|
container_name: picoclaw-gateway-full
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- gateway
|
||||||
|
volumes:
|
||||||
|
# Configuration file
|
||||||
|
- ../config/config.json:/root/.picoclaw/config.json:ro
|
||||||
|
# Persistent workspace (sessions, memory, logs)
|
||||||
|
- picoclaw-workspace:/root/.picoclaw/workspace
|
||||||
|
# NPM cache for faster MCP server installs
|
||||||
|
- picoclaw-npm-cache:/root/.npm
|
||||||
|
command: ["gateway"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
picoclaw-workspace:
|
||||||
|
picoclaw-npm-cache: # Cache npm packages to speed up MCP server installations
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
services:
|
services:
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# PicoClaw Agent (one-shot query)
|
# PicoClaw Agent (one-shot query)
|
||||||
# docker compose run --rm picoclaw-agent -m "Hello"
|
# docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello"
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-agent:
|
picoclaw-agent:
|
||||||
build:
|
image: docker.io/sipeed/picoclaw:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: picoclaw-agent
|
container_name: picoclaw-agent
|
||||||
profiles:
|
profiles:
|
||||||
- agent
|
- agent
|
||||||
|
|
@ -14,33 +12,23 @@ services:
|
||||||
#extra_hosts:
|
#extra_hosts:
|
||||||
# - "host.docker.internal:host-gateway"
|
# - "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
- ./data:/root/.picoclaw
|
||||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
|
||||||
entrypoint: ["picoclaw", "agent"]
|
entrypoint: ["picoclaw", "agent"]
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# PicoClaw Gateway (Long-running Bot)
|
# PicoClaw Gateway (Long-running Bot)
|
||||||
# docker compose up picoclaw-gateway
|
# docker compose -f docker/docker-compose.yml up picoclaw-gateway
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-gateway:
|
picoclaw-gateway:
|
||||||
build:
|
image: docker.io/sipeed/picoclaw:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: picoclaw-gateway
|
container_name: picoclaw-gateway
|
||||||
restart: unless-stopped
|
restart: on-failure
|
||||||
profiles:
|
profiles:
|
||||||
- gateway
|
- gateway
|
||||||
# Uncomment to access host network; leave commented unless needed.
|
# Uncomment to access host network; leave commented unless needed.
|
||||||
#extra_hosts:
|
#extra_hosts:
|
||||||
# - "host.docker.internal:host-gateway"
|
# - "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
# Configuration file
|
- ./data:/root/.picoclaw
|
||||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
|
||||||
# Persistent workspace (sessions, memory, logs)
|
|
||||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
|
||||||
command: ["gateway"]
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
picoclaw-workspace:
|
|
||||||
15
docker/entrypoint.sh
Normal file
15
docker/entrypoint.sh
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# First-run: neither config nor workspace exists.
|
||||||
|
# If config.json is already mounted but workspace is missing we skip onboard to
|
||||||
|
# avoid the interactive "Overwrite? (y/n)" prompt hanging in a non-TTY container.
|
||||||
|
if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.json" ]; then
|
||||||
|
picoclaw onboard
|
||||||
|
echo ""
|
||||||
|
echo "First-run setup complete."
|
||||||
|
echo "Edit ${HOME}/.picoclaw/config.json (add your API key, etc.) then restart the container."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec picoclaw gateway "$@"
|
||||||
|
|
@ -11,7 +11,9 @@ Discord 是一个专为社区设计的免费语音、视频和文本聊天应用
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
"mention_only": false
|
"group_trigger": {
|
||||||
|
"mention_only": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -22,7 +24,7 @@ Discord 是一个专为社区设计的免费语音、视频和文本聊天应用
|
||||||
| enabled | bool | 是 | 是否启用 Discord 频道 |
|
| enabled | bool | 是 | 是否启用 Discord 频道 |
|
||||||
| token | string | 是 | Discord 机器人 Token |
|
| token | string | 是 | Discord 机器人 Token |
|
||||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||||
| mention_only | bool | 否 | 是否仅响应提及机器人的消息 |
|
| group_trigger | object | 否 | 群组触发设置(示例: { "mention_only": false }) |
|
||||||
|
|
||||||
## 设置流程
|
## 设置流程
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18791,
|
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
|
@ -25,9 +23,7 @@ PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的
|
||||||
| enabled | bool | 是 | 是否启用 LINE Channel |
|
| enabled | bool | 是 | 是否启用 LINE Channel |
|
||||||
| channel_secret | string | 是 | LINE Messaging API 的 Channel Secret |
|
| channel_secret | string | 是 | LINE Messaging API 的 Channel Secret |
|
||||||
| channel_access_token | string | 是 | LINE Messaging API 的 Channel Access Token |
|
| channel_access_token | string | 是 | LINE Messaging API 的 Channel Access Token |
|
||||||
| webhook_host | string | 是 | Webhook 监听的主机地址 (通常为 0.0.0.0) |
|
| webhook_path | string | 否 | Webhook 的路径 (默认为 /webhook/line) |
|
||||||
| webhook_port | int | 是 | Webhook 监听的端口 (默认为 18791) |
|
|
||||||
| webhook_path | string | 是 | Webhook 的路径 (默认为 /webhook/line) |
|
|
||||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||||
|
|
||||||
## 设置流程
|
## 设置流程
|
||||||
|
|
@ -35,7 +31,8 @@ PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的
|
||||||
1. 前往 [LINE Developers Console](https://developers.line.biz/console/) 创建一个服务提供商和一个 Messaging API Channel
|
1. 前往 [LINE Developers Console](https://developers.line.biz/console/) 创建一个服务提供商和一个 Messaging API Channel
|
||||||
2. 获取 Channel Secret 和 Channel Access Token
|
2. 获取 Channel Secret 和 Channel Access Token
|
||||||
3. 配置Webhook:
|
3. 配置Webhook:
|
||||||
- Line要求Webhook必须使用HTTPS协议,因此需要部署一个支持HTTPS的服务器,或者使用反向代理工具如ngrok将本地服务器暴露到公网
|
- LINE 要求 Webhook 必须使用 HTTPS 协议,因此需要部署一个支持 HTTPS 的服务器,或者使用反向代理工具如 ngrok 将本地服务器暴露到公网
|
||||||
- 将 Webhook URL 设置为 `https://your-domain.com/webhook/line`
|
- PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790
|
||||||
|
- 将 Webhook URL 设置为 `https://your-domain.com/webhook/line`,然后将外部域名反向代理到本机的 Gateway(默认端口 18790)
|
||||||
- 启用 Webhook 并验证 URL
|
- 启用 Webhook 并验证 URL
|
||||||
4. 将 Channel Secret 和 Channel Access Token 填入配置文件中
|
4. 将 Channel Secret 和 Channel Access Token 填入配置文件中
|
||||||
|
|
|
||||||
116
docs/channels/wecom/wecom_aibot/README.zh.md
Normal file
116
docs/channels/wecom/wecom_aibot/README.zh.md
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
# 企业微信智能机器人 (AI Bot)
|
||||||
|
|
||||||
|
企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议,并支持超时后通过 `response_url` 主动推送最终回复。
|
||||||
|
|
||||||
|
## 与其他 WeCom 通道的对比
|
||||||
|
|
||||||
|
| 特性 | WeCom Bot | WeCom App | **WeCom AI Bot** |
|
||||||
|
|------|-----------|-----------|-----------------|
|
||||||
|
| 私聊 | ✅ | ✅ | ✅ |
|
||||||
|
| 群聊 | ✅ | ❌ | ✅ |
|
||||||
|
| 流式输出 | ❌ | ❌ | ✅ |
|
||||||
|
| 超时主动推送 | ❌ | ✅ | ✅ |
|
||||||
|
| 配置复杂度 | 低 | 高 | 中 |
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_aibot": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
"allow_from": [],
|
||||||
|
"welcome_message": "你好!有什么可以帮助你的吗?",
|
||||||
|
"max_steps": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 描述 |
|
||||||
|
| ---------------- | ------ | ---- | -------------------------------------------------- |
|
||||||
|
| token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 |
|
||||||
|
| encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 |
|
||||||
|
| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-aibot) |
|
||||||
|
| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 |
|
||||||
|
| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 |
|
||||||
|
| reply_timeout | int | 否 | 回复超时时间(秒,默认:5) |
|
||||||
|
| max_steps | int | 否 | Agent 最大执行步骤数(默认:10) |
|
||||||
|
|
||||||
|
## 设置流程
|
||||||
|
|
||||||
|
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
|
||||||
|
2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot
|
||||||
|
3. 在 AI Bot 配置页面,填写"消息接收"信息:
|
||||||
|
- **URL**:`http://<your-server-ip>:18791/webhook/wecom-aibot`
|
||||||
|
- **Token**:随机生成或自定义
|
||||||
|
- **EncodingAESKey**:点击"随机生成",得到 43 字符密钥
|
||||||
|
4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件,启动服务后回到管理后台保存(企业微信会发送验证请求)
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 服务器需要能被企业微信服务器访问。如在内网/本地开发,可使用 [ngrok](https://ngrok.com) 或 frp 做内网穿透。
|
||||||
|
|
||||||
|
## 流式响应协议
|
||||||
|
|
||||||
|
WeCom AI Bot 使用"流式拉取"协议,区别于普通 Webhook 的一次性回复:
|
||||||
|
|
||||||
|
```
|
||||||
|
用户发消息
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
PicoClaw 立即返回 {finish: false}(Agent 开始处理)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
企业微信每隔约 1 秒拉取一次 {msgtype: "stream", stream: {id: "..."}}
|
||||||
|
│
|
||||||
|
├─ Agent 未完成 → 返回 {finish: false}(继续等待)
|
||||||
|
│
|
||||||
|
└─ Agent 完成 → 返回 {finish: true, content: "回答内容"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**超时处理**(任务超过 30 秒):
|
||||||
|
|
||||||
|
若 Agent 处理时间超过约 30 秒(企业微信最大轮询窗口为 6 分钟),PicoClaw 会:
|
||||||
|
|
||||||
|
1. 立即关闭流,向用户显示「⏳ 正在处理中,请稍候,结果将稍后发送。」
|
||||||
|
2. Agent 继续在后台运行
|
||||||
|
3. Agent 完成后,通过消息中携带的 `response_url` 将最终回复主动推送给用户
|
||||||
|
|
||||||
|
> `response_url` 由企业微信颁发,有效期 1 小时,只可使用一次,无需加密,直接 POST markdown 消息体即可。
|
||||||
|
|
||||||
|
## 欢迎语
|
||||||
|
|
||||||
|
配置 `welcome_message` 后,当用户打开与 AI Bot 的聊天窗口时(`enter_chat` 事件),PicoClaw 会自动回复该欢迎语。留空则静默忽略。
|
||||||
|
|
||||||
|
```json
|
||||||
|
"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 回调 URL 验证失败
|
||||||
|
|
||||||
|
- 确认服务器防火墙已开放对应端口(默认 18791)
|
||||||
|
- 确认 `token` 与 `encoding_aes_key` 填写正确
|
||||||
|
- 检查 PicoClaw 日志是否收到了来自企业微信的 GET 请求
|
||||||
|
|
||||||
|
### 消息没有回复
|
||||||
|
|
||||||
|
- 检查 `allow_from` 是否意外限制了发送者
|
||||||
|
- 查看日志中是否出现 `context canceled` 或 Agent 错误
|
||||||
|
- 确认 Agent 配置(`model_name` 等)正确
|
||||||
|
|
||||||
|
### 超长任务没有收到最终推送
|
||||||
|
|
||||||
|
- 确认消息回调中携带了 `response_url`(仅企业微信新版 AI Bot 支持)
|
||||||
|
- 确认服务器能主动访问外网(需向 `response_url` POST 请求)
|
||||||
|
- 查看日志关键词 `response_url mode` 和 `Sending reply via response_url`
|
||||||
|
|
||||||
|
## 参考文档
|
||||||
|
|
||||||
|
- [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/100719)
|
||||||
|
- [流式响应协议说明](https://developer.work.weixin.qq.com/document/path/100719)
|
||||||
|
- [response_url 主动回复](https://developer.work.weixin.qq.com/document/path/101138)
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
"agent_id": 1000002,
|
"agent_id": 1000002,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
"webhook_path": "/webhook/wecom-app",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reply_timeout": 5
|
"reply_timeout": 5
|
||||||
|
|
@ -31,8 +29,6 @@
|
||||||
| agent_id | int | 是 | 应用程序代理 ID |
|
| agent_id | int | 是 | 应用程序代理 ID |
|
||||||
| token | string | 是 | 回调验证令牌 |
|
| token | string | 是 | 回调验证令牌 |
|
||||||
| encoding_aes_key | string | 是 | 43 字符 AES 密钥 |
|
| encoding_aes_key | string | 是 | 43 字符 AES 密钥 |
|
||||||
| webhook_host | string | 否 | HTTP 服务器绑定地址 |
|
|
||||||
| webhook_port | int | 否 | HTTP 服务器端口(默认:18792) |
|
|
||||||
| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-app) |
|
| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-app) |
|
||||||
| allow_from | array | 否 | 用户 ID 白名单 |
|
| allow_from | array | 否 | 用户 ID 白名单 |
|
||||||
| reply_timeout | int | 否 | 回复超时时间(秒) |
|
| reply_timeout | int | 否 | 回复超时时间(秒) |
|
||||||
|
|
@ -45,3 +41,5 @@
|
||||||
4. 在应用设置中配置“接收消息”,获取 Token 和 EncodingAESKey
|
4. 在应用设置中配置“接收消息”,获取 Token 和 EncodingAESKey
|
||||||
5. 设置回调 URL 为 `http://<your-server-ip>:<port>/webhook/wecom-app`
|
5. 设置回调 URL 为 `http://<your-server-ip>:<port>/webhook/wecom-app`
|
||||||
6. 将 CorpID, Secret, AgentID 等信息填入配置文件
|
6. 将 CorpID, Secret, AgentID 等信息填入配置文件
|
||||||
|
|
||||||
|
注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790。如需从公网接收回调,请把外部域名反向代理到 Gateway(默认端口 18790)。
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18793,
|
|
||||||
"webhook_path": "/webhook/wecom",
|
"webhook_path": "/webhook/wecom",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reply_timeout": 5
|
"reply_timeout": 5
|
||||||
|
|
@ -27,8 +25,6 @@
|
||||||
| token | string | 是 | 签名验证代币 |
|
| token | string | 是 | 签名验证代币 |
|
||||||
| encoding_aes_key | string | 是 | 用于解密的 43 字符 AES 密钥 |
|
| encoding_aes_key | string | 是 | 用于解密的 43 字符 AES 密钥 |
|
||||||
| webhook_url | string | 是 | 用于发送回复的企业微信群聊机器人 Webhook URL |
|
| webhook_url | string | 是 | 用于发送回复的企业微信群聊机器人 Webhook URL |
|
||||||
| webhook_host | string | 否 | HTTP 服务器绑定地址(默认:0.0.0.0) |
|
|
||||||
| webhook_port | int | 否 | HTTP 服务器端口(默认:18793) |
|
|
||||||
| webhook_path | string | 否 | Webhook 端点路径(默认:/webhook/wecom) |
|
| webhook_path | string | 否 | Webhook 端点路径(默认:/webhook/wecom) |
|
||||||
| allow_from | array | 否 | 用户 ID 白名单(空值 = 允许所有用户) |
|
| allow_from | array | 否 | 用户 ID 白名单(空值 = 允许所有用户) |
|
||||||
| reply_timeout | int | 否 | 回复超时时间(单位:秒,默认值:5) |
|
| reply_timeout | int | 否 | 回复超时时间(单位:秒,默认值:5) |
|
||||||
|
|
@ -39,3 +35,5 @@
|
||||||
2. 获取 Webhook URL
|
2. 获取 Webhook URL
|
||||||
3. (如需接收消息) 在机器人配置页面设置接收消息的 API 地址(回调地址)以及 Token 和 EncodingAESKey
|
3. (如需接收消息) 在机器人配置页面设置接收消息的 API 地址(回调地址)以及 Token 和 EncodingAESKey
|
||||||
4. 将相关信息填入配置文件
|
4. 将相关信息填入配置文件
|
||||||
|
|
||||||
|
注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790。如需从公网接收回调,请把外部域名反向代理到 Gateway(默认端口 18790)。
|
||||||
|
|
|
||||||
61
docs/design/issue-783-investigation-and-fix-plan.zh.md
Normal file
61
docs/design/issue-783-investigation-and-fix-plan.zh.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Issue #783 调研与修复执行文档
|
||||||
|
|
||||||
|
## 1. 问题澄清(已确认)
|
||||||
|
|
||||||
|
- 现象:当 `agents.*.model.primary/fallbacks` 使用 `model_name` 别名(如 `step-3.5-flash`)时,fallback 链路将别名当作真实 `provider/model` 解析,导致 `provider` 可能为空、`model` 可能错误。
|
||||||
|
- 根因:`ResolveCandidates` 仅对字符串做 `ParseModelRef`,未先通过 `model_list` 将别名映射到真实 `model` 字段。
|
||||||
|
- 影响:
|
||||||
|
- fallback 执行可能把别名直接发给 OpenAI-compatible provider,触发 `Unknown Model`。
|
||||||
|
- `defaults.provider` 为空时,日志出现 `provider=` 空值。
|
||||||
|
|
||||||
|
## 2. 本次目标
|
||||||
|
|
||||||
|
- 修复 fallback 候选解析:优先通过 `model_list` 解析别名。
|
||||||
|
- 兼容旧行为:若未命中 `model_list`,继续走原有 `ParseModelRef` 兜底。
|
||||||
|
- 补充测试:覆盖别名、嵌套路径模型(如 `openrouter/stepfun/...`)、空默认 provider。
|
||||||
|
- 验证代码风格:与当前仓库风格保持一致(命名、错误处理、测试结构)。
|
||||||
|
|
||||||
|
## 3. 联网最佳实践调研结论(已完成)
|
||||||
|
|
||||||
|
- [x] 查阅 OpenAI-compatible 网关(如 OpenRouter)对 `model` 字段的推荐处理。
|
||||||
|
- [x] 查阅多 provider/fallback 设计最佳实践(候选解析、日志可观测性)。
|
||||||
|
- [x] 将外部建议映射为本仓库可执行约束。
|
||||||
|
|
||||||
|
外部参考要点(来自 OpenRouter/LiteLLM/Cloudflare AI Gateway 等官方文档):
|
||||||
|
|
||||||
|
- 优先显式配置,不依赖字符串切分推断 provider。
|
||||||
|
- 对网关模型标识应保留完整路径语义,避免截断导致 Unknown Model。
|
||||||
|
- fallback 与 primary 应复用同一解析策略,避免“主路径正确、降级路径错误”。
|
||||||
|
|
||||||
|
参考链接:
|
||||||
|
|
||||||
|
- OpenRouter Provider Routing: https://openrouter.ai/docs/guides/routing/provider-selection
|
||||||
|
- OpenRouter Model Fallbacks: https://openrouter.ai/docs/guides/routing/model-fallbacks
|
||||||
|
- OpenRouter Chat Completion API: https://openrouter.ai/docs/api-reference/chat-completion
|
||||||
|
- LiteLLM Router Architecture: https://docs.litellm.ai/docs/router_architecture
|
||||||
|
- Cloudflare AI Gateway Chat Completion: https://developers.cloudflare.com/ai-gateway/usage/chat-completion/
|
||||||
|
|
||||||
|
与本仓库对应的可执行约束:
|
||||||
|
|
||||||
|
- 在 fallback candidate 构建阶段先做 `model_name -> model_list.model` 映射。
|
||||||
|
- 未命中映射时保留旧解析行为,保证兼容性。
|
||||||
|
- 用新增测试锁定“别名 + 嵌套模型路径 + 空默认 provider”场景。
|
||||||
|
|
||||||
|
## 4. 实施步骤(顺序执行)
|
||||||
|
|
||||||
|
- [x] Step 1: 对齐现有代码模式,定位最小改动点(`pkg/agent` + `pkg/providers`)。
|
||||||
|
- [x] Step 2: 实现“基于 model_list 的 fallback 候选解析”。
|
||||||
|
- [x] Step 3: 增加/更新单元测试,覆盖 issue 场景。
|
||||||
|
- [x] Step 4: 代码风格一致性复核(与现有文件风格对照)。
|
||||||
|
- [x] Step 5: 运行质量门禁(LSP + `make check`)。
|
||||||
|
|
||||||
|
## 5. 执行记录
|
||||||
|
|
||||||
|
- 状态:已完成
|
||||||
|
- 已完成改动:
|
||||||
|
- `pkg/providers/fallback.go`:新增 `ResolveCandidatesWithLookup`,并保持 `ResolveCandidates` 向后兼容。
|
||||||
|
- `pkg/agent/instance.go`:在构建 fallback candidates 前,优先通过 `model_list` 解析别名,并对无协议模型补齐默认 `openai/` 前缀后再解析。
|
||||||
|
- `pkg/providers/fallback_test.go`:新增别名解析与去重测试。
|
||||||
|
- `pkg/agent/instance_test.go`:新增 agent 侧别名解析到嵌套模型路径、无协议模型解析测试。
|
||||||
|
- 风格对齐检查(完成):与 `pkg/providers/fallback_test.go`、`pkg/providers/model_ref_test.go` 现有模式一致。
|
||||||
|
- 质量验证(完成):先 `make generate`,后 `make check` 全量通过。
|
||||||
|
|
@ -117,6 +117,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
||||||
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
|
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
|
||||||
| `rpm` | No | Requests per minute limit |
|
| `rpm` | No | Requests per minute limit |
|
||||||
| `max_tokens_field` | No | Field name for max tokens |
|
| `max_tokens_field` | No | Field name for max tokens |
|
||||||
|
| `request_timeout` | No | HTTP request timeout in seconds; `<=0` uses default `120s` |
|
||||||
|
|
||||||
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
|
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
|
||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": { ... },
|
"web": { ... },
|
||||||
|
"mcp": { ... },
|
||||||
"exec": { ... },
|
"exec": { ... },
|
||||||
"cron": { ... },
|
"cron": { ... },
|
||||||
"skills": { ... }
|
"skills": { ... }
|
||||||
|
|
@ -21,35 +22,35 @@ Web tools are used for web search and fetching.
|
||||||
|
|
||||||
### Brave
|
### Brave
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
| ------------- | ------ | ------- | ------------------------- |
|
||||||
| `enabled` | bool | false | Enable Brave search |
|
| `enabled` | bool | false | Enable Brave search |
|
||||||
| `api_key` | string | - | Brave Search API key |
|
| `api_key` | string | - | Brave Search API key |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
||||||
### DuckDuckGo
|
### DuckDuckGo
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
| ------------- | ---- | ------- | ------------------------- |
|
||||||
| `enabled` | bool | true | Enable DuckDuckGo search |
|
| `enabled` | bool | true | Enable DuckDuckGo search |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
||||||
### Perplexity
|
### Perplexity
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
| ------------- | ------ | ------- | ------------------------- |
|
||||||
| `enabled` | bool | false | Enable Perplexity search |
|
| `enabled` | bool | false | Enable Perplexity search |
|
||||||
| `api_key` | string | - | Perplexity API key |
|
| `api_key` | string | - | Perplexity API key |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
||||||
## Exec Tool
|
## Exec Tool
|
||||||
|
|
||||||
The exec tool is used to execute shell commands.
|
The exec tool is used to execute shell commands.
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
| ---------------------- | ----- | ------- | ------------------------------------------ |
|
||||||
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
||||||
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
||||||
|
|
||||||
### Functionality
|
### Functionality
|
||||||
|
|
||||||
|
|
@ -80,10 +81,7 @@ By default, PicoClaw blocks the following dangerous commands:
|
||||||
"tools": {
|
"tools": {
|
||||||
"exec": {
|
"exec": {
|
||||||
"enable_deny_patterns": true,
|
"enable_deny_patterns": true,
|
||||||
"custom_deny_patterns": [
|
"custom_deny_patterns": ["\\brm\\s+-r\\b", "\\bkillall\\s+python"]
|
||||||
"\\brm\\s+-r\\b",
|
|
||||||
"\\bkillall\\s+python"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -93,9 +91,84 @@ By default, PicoClaw blocks the following dangerous commands:
|
||||||
|
|
||||||
The cron tool is used for scheduling periodic tasks.
|
The cron tool is used for scheduling periodic tasks.
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
| ---------------------- | ---- | ------- | ---------------------------------------------- |
|
||||||
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
||||||
|
|
||||||
|
## MCP Tool
|
||||||
|
|
||||||
|
The MCP tool enables integration with external Model Context Protocol servers.
|
||||||
|
|
||||||
|
### Global Config
|
||||||
|
|
||||||
|
| Config | Type | Default | Description |
|
||||||
|
| --------- | ------ | ------- | ----------------------------------- |
|
||||||
|
| `enabled` | bool | false | Enable MCP integration globally |
|
||||||
|
| `servers` | object | `{}` | Map of server name to server config |
|
||||||
|
|
||||||
|
### Per-Server Config
|
||||||
|
|
||||||
|
| Config | Type | Required | Description |
|
||||||
|
| ---------- | ------ | -------- | ------------------------------------------ |
|
||||||
|
| `enabled` | bool | yes | Enable this MCP server |
|
||||||
|
| `type` | string | no | Transport type: `stdio`, `sse`, `http` |
|
||||||
|
| `command` | string | stdio | Executable command for stdio transport |
|
||||||
|
| `args` | array | no | Command arguments for stdio transport |
|
||||||
|
| `env` | object | no | Environment variables for stdio process |
|
||||||
|
| `env_file` | string | no | Path to environment file for stdio process |
|
||||||
|
| `url` | string | sse/http | Endpoint URL for `sse`/`http` transport |
|
||||||
|
| `headers` | object | no | HTTP headers for `sse`/`http` transport |
|
||||||
|
|
||||||
|
### Transport Behavior
|
||||||
|
|
||||||
|
- If `type` is omitted, transport is auto-detected:
|
||||||
|
- `url` is set → `sse`
|
||||||
|
- `command` is set → `stdio`
|
||||||
|
- `http` and `sse` both use `url` + optional `headers`.
|
||||||
|
- `env` and `env_file` are only applied to `stdio` servers.
|
||||||
|
|
||||||
|
### Configuration Examples
|
||||||
|
|
||||||
|
#### 1) Stdio MCP server
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"mcp": {
|
||||||
|
"enabled": true,
|
||||||
|
"servers": {
|
||||||
|
"filesystem": {
|
||||||
|
"enabled": true,
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2) Remote SSE/HTTP MCP server
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"mcp": {
|
||||||
|
"enabled": true,
|
||||||
|
"servers": {
|
||||||
|
"remote-mcp": {
|
||||||
|
"enabled": true,
|
||||||
|
"type": "sse",
|
||||||
|
"url": "https://example.com/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer YOUR_TOKEN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Skills Tool
|
## Skills Tool
|
||||||
|
|
||||||
|
|
@ -103,13 +176,14 @@ The skills tool configures skill discovery and installation via registries like
|
||||||
|
|
||||||
### Registries
|
### Registries
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
| ---------------------------------- | ------ | -------------------- | ----------------------- |
|
||||||
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
||||||
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
|
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
|
||||||
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
|
| `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits |
|
||||||
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
|
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
|
||||||
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
|
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
|
||||||
|
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
|
||||||
|
|
||||||
### Configuration Example
|
### Configuration Example
|
||||||
|
|
||||||
|
|
@ -121,6 +195,7 @@ The skills tool configures skill discovery and installation via registries like
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"base_url": "https://clawhub.ai",
|
"base_url": "https://clawhub.ai",
|
||||||
|
"auth_token": "",
|
||||||
"search_path": "/api/v1/search",
|
"search_path": "/api/v1/search",
|
||||||
"skills_path": "/api/v1/skills",
|
"skills_path": "/api/v1/skills",
|
||||||
"download_path": "/api/v1/download"
|
"download_path": "/api/v1/download"
|
||||||
|
|
@ -136,8 +211,10 @@ The skills tool configures skill discovery and installation via registries like
|
||||||
All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_<SECTION>_<KEY>`:
|
All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_<SECTION>_<KEY>`:
|
||||||
|
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
||||||
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
||||||
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
||||||
|
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
||||||
|
|
||||||
Note: Array-type environment variables are not currently supported and must be set via the config file.
|
Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than environment variables.
|
||||||
|
|
|
||||||
43
docs/troubleshooting.md
Normal file
43
docs/troubleshooting.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## "model ... not found in model_list" or OpenRouter "free is not a valid model ID"
|
||||||
|
|
||||||
|
**Symptom:** You see either:
|
||||||
|
|
||||||
|
- `Error creating provider: model "openrouter/free" not found in model_list`
|
||||||
|
- OpenRouter returns 400: `"free is not a valid model ID"`
|
||||||
|
|
||||||
|
**Cause:** The `model` field in your `model_list` entry is what gets sent to the API. For OpenRouter you must use the **full** model ID, not a shorthand.
|
||||||
|
|
||||||
|
- **Wrong:** `"model": "free"` → OpenRouter receives `free` and rejects it.
|
||||||
|
- **Right:** `"model": "openrouter/free"` → OpenRouter receives `openrouter/free` (auto free-tier routing).
|
||||||
|
|
||||||
|
**Fix:** In `~/.picoclaw/config.json` (or your config path):
|
||||||
|
|
||||||
|
1. **agents.defaults.model** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`).
|
||||||
|
2. That entry’s **model** must be a valid OpenRouter model ID, for example:
|
||||||
|
- `"openrouter/free"` – auto free-tier
|
||||||
|
- `"google/gemini-2.0-flash-exp:free"`
|
||||||
|
- `"meta-llama/llama-3.1-8b-instruct:free"`
|
||||||
|
|
||||||
|
Example snippet:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "openrouter-free"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "openrouter-free",
|
||||||
|
"model": "openrouter/free",
|
||||||
|
"api_key": "sk-or-v1-YOUR_OPENROUTER_KEY",
|
||||||
|
"api_base": "https://openrouter.ai/api/v1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Get your key at [OpenRouter Keys](https://openrouter.ai/keys).
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
# 企业微信自建应用 (WeCom App) 配置指南
|
|
||||||
|
|
||||||
本文档介绍如何在 PicoClaw 中配置企业微信自建应用 (wecom-app) 通道。
|
|
||||||
|
|
||||||
## 功能特性
|
|
||||||
|
|
||||||
| 功能 | 支持状态 |
|
|
||||||
|------|---------|
|
|
||||||
| 被动接收消息 | ✅ |
|
|
||||||
| 主动发送消息 | ✅ |
|
|
||||||
| 私聊 | ✅ |
|
|
||||||
| 群聊 | ❌ |
|
|
||||||
|
|
||||||
## 配置步骤
|
|
||||||
|
|
||||||
### 1. 企业微信后台配置
|
|
||||||
|
|
||||||
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
|
|
||||||
2. 进入"应用管理" → 选择自建应用
|
|
||||||
3. 记录以下信息:
|
|
||||||
- **AgentId**: 应用详情页显示
|
|
||||||
- **Secret**: 点击"查看"获取
|
|
||||||
4. 进入"我的企业"页面,记录 **企业ID** (CorpID)
|
|
||||||
|
|
||||||
### 2. 接收消息配置
|
|
||||||
|
|
||||||
1. 在应用详情页,点击"接收消息"的"设置API接收"
|
|
||||||
2. 填写以下信息:
|
|
||||||
- **URL**: `http://your-server:18792/webhook/wecom-app`
|
|
||||||
- **Token**: 随机生成或自定义(用于签名验证)
|
|
||||||
- **EncodingAESKey**: 点击"随机生成"生成43字符的密钥
|
|
||||||
3. 点击"保存"时,企业微信会发送验证请求
|
|
||||||
|
|
||||||
### 3. PicoClaw 配置
|
|
||||||
|
|
||||||
在 `config.json` 中添加以下配置:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"channels": {
|
|
||||||
"wecom_app": {
|
|
||||||
"enabled": true,
|
|
||||||
"corp_id": "wwxxxxxxxxxxxxxxxx", // 企业ID
|
|
||||||
"corp_secret": "xxxxxxxxxxxxxxxxxxxxxxxx", // 应用Secret
|
|
||||||
"agent_id": 1000002, // 应用AgentId
|
|
||||||
"token": "your_token", // 接收消息配置的Token
|
|
||||||
"encoding_aes_key": "your_encoding_aes_key", // 接收消息配置的EncodingAESKey
|
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
|
||||||
"allow_from": [],
|
|
||||||
"reply_timeout": 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### 1. 回调URL验证失败
|
|
||||||
|
|
||||||
**症状**: 企业微信保存API接收消息时提示验证失败
|
|
||||||
|
|
||||||
**检查项**:
|
|
||||||
- 确认服务器防火墙已开放 18792 端口
|
|
||||||
- 确认 `corp_id`、`token`、`encoding_aes_key` 配置正确
|
|
||||||
- 查看 PicoClaw 日志是否有请求到达
|
|
||||||
|
|
||||||
### 2. 中文消息解密失败
|
|
||||||
|
|
||||||
**症状**: 发送中文消息时出现 `invalid padding size` 错误
|
|
||||||
|
|
||||||
**原因**: 企业微信使用非标准的 PKCS7 填充(32字节块大小)
|
|
||||||
|
|
||||||
**解决**: 确保使用最新版本的 PicoClaw,已修复此问题。
|
|
||||||
|
|
||||||
### 3. 端口冲突
|
|
||||||
|
|
||||||
**症状**: 启动时提示端口已被占用
|
|
||||||
|
|
||||||
**解决**: 修改 `webhook_port` 为其他端口,如 18794
|
|
||||||
|
|
||||||
## 技术细节
|
|
||||||
|
|
||||||
### 加密算法
|
|
||||||
|
|
||||||
- **算法**: AES-256-CBC
|
|
||||||
- **密钥**: EncodingAESKey Base64解码后的32字节
|
|
||||||
- **IV**: AESKey的前16字节
|
|
||||||
- **填充**: PKCS7(块大小为32字节,非标准16字节)
|
|
||||||
- **消息格式**: XML
|
|
||||||
|
|
||||||
### 消息结构
|
|
||||||
|
|
||||||
解密后的消息格式:
|
|
||||||
```
|
|
||||||
random(16B) + msg_len(4B) + msg + receiveid
|
|
||||||
```
|
|
||||||
|
|
||||||
其中 `receiveid` 对于自建应用是 `corp_id`。
|
|
||||||
|
|
||||||
## 调试
|
|
||||||
|
|
||||||
启用调试模式查看详细日志:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
picoclaw gateway --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
关键日志标识:
|
|
||||||
- `wecom_app`: WeCom App 通道相关日志
|
|
||||||
- `wecom_common`: 加密解密相关日志
|
|
||||||
|
|
||||||
## 参考文档
|
|
||||||
|
|
||||||
- [企业微信官方文档 - 接收消息](https://developer.work.weixin.qq.com/document/path/96211)
|
|
||||||
- [企业微信官方加解密库](https://github.com/sbzhu/weworkapi_golang)
|
|
||||||
34
go.mod
34
go.mod
|
|
@ -8,25 +8,58 @@ require (
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
github.com/chzyer/readline v1.5.1
|
github.com/chzyer/readline v1.5.1
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/h2non/filetype v1.1.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.3.0
|
||||||
github.com/mymmrac/telego v1.6.0
|
github.com/mymmrac/telego v1.6.0
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||||
github.com/openai/openai-go/v3 v3.22.0
|
github.com/openai/openai-go/v3 v3.22.0
|
||||||
|
github.com/rivo/tview v0.42.0
|
||||||
github.com/slack-go/slack v0.17.3
|
github.com/slack-go/slack v0.17.3
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tencent-connect/botgo v0.2.1
|
github.com/tencent-connect/botgo v0.2.1
|
||||||
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
||||||
golang.org/x/oauth2 v0.35.0
|
golang.org/x/oauth2 v0.35.0
|
||||||
|
golang.org/x/time v0.14.0
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
|
modernc.org/sqlite v1.46.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/beeper/argo-go v1.1.2 // indirect
|
||||||
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/rs/zerolog v1.34.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||||
|
go.mau.fi/libsignal v0.2.1 // indirect
|
||||||
|
go.mau.fi/util v0.9.6 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||||
|
golang.org/x/term v0.40.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.67.6 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
rsc.io/qr v0.2.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
@ -50,6 +83,7 @@ require (
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||||
github.com/valyala/fastjson v1.6.7 // indirect
|
github.com/valyala/fastjson v1.6.7 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
golang.org/x/arch v0.24.0 // indirect
|
golang.org/x/arch v0.24.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.50.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
|
|
|
||||||
120
go.sum
120
go.sum
|
|
@ -1,10 +1,20 @@
|
||||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
||||||
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
|
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
||||||
|
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
||||||
|
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
||||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
|
@ -25,14 +35,25 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
||||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||||
|
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
|
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||||
|
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||||
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
||||||
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
||||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||||
|
|
@ -42,8 +63,11 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m
|
||||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
|
@ -63,6 +87,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
|
@ -72,6 +98,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
||||||
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
||||||
|
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
|
||||||
|
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
|
@ -91,8 +121,25 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE=
|
||||||
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
||||||
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
|
@ -105,13 +152,27 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||||
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
||||||
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||||
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
|
||||||
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
|
||||||
|
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
|
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||||
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
||||||
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
|
@ -153,11 +214,21 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy
|
||||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
||||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
|
||||||
|
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
|
||||||
|
go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
|
||||||
|
go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
|
||||||
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
|
||||||
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
|
@ -171,10 +242,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
||||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
|
@ -217,8 +292,11 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
|
@ -227,6 +305,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||||
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
|
@ -234,8 +314,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
|
@ -243,6 +325,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|
@ -255,6 +339,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
|
@ -269,3 +355,33 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||||
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||||
|
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||||
|
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||||
|
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||||
|
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -33,9 +34,17 @@ type ContextBuilder struct {
|
||||||
// created (didn't exist at cache time, now exist) or deleted (existed at
|
// created (didn't exist at cache time, now exist) or deleted (existed at
|
||||||
// cache time, now gone) — both of which should trigger a cache rebuild.
|
// cache time, now gone) — both of which should trigger a cache rebuild.
|
||||||
existedAtCache map[string]bool
|
existedAtCache map[string]bool
|
||||||
|
|
||||||
|
// skillFilesAtCache snapshots the skill tree file set and mtimes at cache
|
||||||
|
// build time. This catches nested file creations/deletions/mtime changes
|
||||||
|
// that may not update the top-level skill root directory mtime.
|
||||||
|
skillFilesAtCache map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func getGlobalConfigDir() string {
|
func getGlobalConfigDir() string {
|
||||||
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -46,8 +55,11 @@ func getGlobalConfigDir() string {
|
||||||
func NewContextBuilder(workspace string) *ContextBuilder {
|
func NewContextBuilder(workspace string) *ContextBuilder {
|
||||||
// builtin skills: skills directory in current project
|
// builtin skills: skills directory in current project
|
||||||
// Use the skills/ directory under the current working directory
|
// Use the skills/ directory under the current working directory
|
||||||
wd, _ := os.Getwd()
|
builtinSkillsDir := strings.TrimSpace(os.Getenv("PICOCLAW_BUILTIN_SKILLS"))
|
||||||
builtinSkillsDir := filepath.Join(wd, "skills")
|
if builtinSkillsDir == "" {
|
||||||
|
wd, _ := os.Getwd()
|
||||||
|
builtinSkillsDir = filepath.Join(wd, "skills")
|
||||||
|
}
|
||||||
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
|
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
|
||||||
|
|
||||||
return &ContextBuilder{
|
return &ContextBuilder{
|
||||||
|
|
@ -147,6 +159,7 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
|
||||||
cb.cachedSystemPrompt = prompt
|
cb.cachedSystemPrompt = prompt
|
||||||
cb.cachedAt = baseline.maxMtime
|
cb.cachedAt = baseline.maxMtime
|
||||||
cb.existedAtCache = baseline.existed
|
cb.existedAtCache = baseline.existed
|
||||||
|
cb.skillFilesAtCache = baseline.skillFiles
|
||||||
|
|
||||||
logger.DebugCF("agent", "System prompt cached",
|
logger.DebugCF("agent", "System prompt cached",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
|
|
@ -166,14 +179,14 @@ func (cb *ContextBuilder) InvalidateCache() {
|
||||||
cb.cachedSystemPrompt = ""
|
cb.cachedSystemPrompt = ""
|
||||||
cb.cachedAt = time.Time{}
|
cb.cachedAt = time.Time{}
|
||||||
cb.existedAtCache = nil
|
cb.existedAtCache = nil
|
||||||
|
cb.skillFilesAtCache = nil
|
||||||
|
|
||||||
logger.DebugCF("agent", "System prompt cache invalidated", nil)
|
logger.DebugCF("agent", "System prompt cache invalidated", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourcePaths returns the workspace source file paths tracked for cache
|
// sourcePaths returns non-skill workspace source files tracked for cache
|
||||||
// invalidation (bootstrap files + memory). The skills directory is handled
|
// invalidation (bootstrap files + memory). Skill roots are handled separately
|
||||||
// separately in sourceFilesChangedLocked because it requires both directory-
|
// because they require both directory-level and recursive file-level checks.
|
||||||
// level and recursive file-level mtime checks.
|
|
||||||
func (cb *ContextBuilder) sourcePaths() []string {
|
func (cb *ContextBuilder) sourcePaths() []string {
|
||||||
return []string{
|
return []string{
|
||||||
filepath.Join(cb.workspace, "AGENTS.md"),
|
filepath.Join(cb.workspace, "AGENTS.md"),
|
||||||
|
|
@ -184,23 +197,39 @@ func (cb *ContextBuilder) sourcePaths() []string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skillRoots returns all skill root directories that can affect
|
||||||
|
// BuildSkillsSummary output (workspace/global/builtin).
|
||||||
|
func (cb *ContextBuilder) skillRoots() []string {
|
||||||
|
if cb.skillsLoader == nil {
|
||||||
|
return []string{filepath.Join(cb.workspace, "skills")}
|
||||||
|
}
|
||||||
|
|
||||||
|
roots := cb.skillsLoader.SkillRoots()
|
||||||
|
if len(roots) == 0 {
|
||||||
|
return []string{filepath.Join(cb.workspace, "skills")}
|
||||||
|
}
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
// cacheBaseline holds the file existence snapshot and the latest observed
|
// cacheBaseline holds the file existence snapshot and the latest observed
|
||||||
// mtime across all tracked paths. Used as the cache reference point.
|
// mtime across all tracked paths. Used as the cache reference point.
|
||||||
type cacheBaseline struct {
|
type cacheBaseline struct {
|
||||||
existed map[string]bool
|
existed map[string]bool
|
||||||
maxMtime time.Time
|
skillFiles map[string]time.Time
|
||||||
|
maxMtime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildCacheBaseline records which tracked paths currently exist and computes
|
// buildCacheBaseline records which tracked paths currently exist and computes
|
||||||
// the latest mtime across all tracked files + skills directory contents.
|
// the latest mtime across all tracked files + skills directory contents.
|
||||||
// Called under write lock when the cache is built.
|
// Called under write lock when the cache is built.
|
||||||
func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
|
func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
|
||||||
skillsDir := filepath.Join(cb.workspace, "skills")
|
skillRoots := cb.skillRoots()
|
||||||
|
|
||||||
// All paths whose existence we track: source files + skills dir.
|
// All paths whose existence we track: source files + all skill roots.
|
||||||
allPaths := append(cb.sourcePaths(), skillsDir)
|
allPaths := append(cb.sourcePaths(), skillRoots...)
|
||||||
|
|
||||||
existed := make(map[string]bool, len(allPaths))
|
existed := make(map[string]bool, len(allPaths))
|
||||||
|
skillFiles := make(map[string]time.Time)
|
||||||
var maxMtime time.Time
|
var maxMtime time.Time
|
||||||
|
|
||||||
for _, p := range allPaths {
|
for _, p := range allPaths {
|
||||||
|
|
@ -211,17 +240,21 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walk skills files to capture their mtimes too.
|
// Walk all skill roots recursively to snapshot skill files and mtimes.
|
||||||
// Use os.Stat (not d.Info) to match the stat method used in
|
// Use os.Stat (not d.Info) for consistency with sourceFilesChanged checks.
|
||||||
// fileChangedSince / skillFilesModifiedSince for consistency.
|
for _, root := range skillRoots {
|
||||||
_ = filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error {
|
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
if walkErr == nil && !d.IsDir() {
|
if walkErr == nil && !d.IsDir() {
|
||||||
if info, err := os.Stat(path); err == nil && info.ModTime().After(maxMtime) {
|
if info, err := os.Stat(path); err == nil {
|
||||||
maxMtime = info.ModTime()
|
skillFiles[path] = info.ModTime()
|
||||||
|
if info.ModTime().After(maxMtime) {
|
||||||
|
maxMtime = info.ModTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return nil
|
||||||
return nil
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
// If no tracked files exist yet (empty workspace), maxMtime is zero.
|
// If no tracked files exist yet (empty workspace), maxMtime is zero.
|
||||||
// Use a very old non-zero time so that:
|
// Use a very old non-zero time so that:
|
||||||
|
|
@ -233,7 +266,7 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
|
||||||
maxMtime = time.Unix(1, 0)
|
maxMtime = time.Unix(1, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cacheBaseline{existed: existed, maxMtime: maxMtime}
|
return cacheBaseline{existed: existed, skillFiles: skillFiles, maxMtime: maxMtime}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceFilesChangedLocked checks whether any workspace source file has been
|
// sourceFilesChangedLocked checks whether any workspace source file has been
|
||||||
|
|
@ -249,27 +282,21 @@ func (cb *ContextBuilder) sourceFilesChangedLocked() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check tracked source files (bootstrap + memory).
|
// Check tracked source files (bootstrap + memory).
|
||||||
for _, p := range cb.sourcePaths() {
|
if slices.ContainsFunc(cb.sourcePaths(), cb.fileChangedSince) {
|
||||||
if cb.fileChangedSince(p) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Skills directory (handled separately from sourcePaths) ---
|
|
||||||
//
|
|
||||||
// 1. Creation/deletion: tracked via existedAtCache, same as bootstrap files.
|
|
||||||
skillsDir := filepath.Join(cb.workspace, "skills")
|
|
||||||
if cb.fileChangedSince(skillsDir) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Structural changes (add/remove entries inside the dir) are reflected
|
// --- Skill roots (workspace/global/builtin) ---
|
||||||
// in the directory's own mtime, which fileChangedSince already checks.
|
|
||||||
//
|
//
|
||||||
// 3. Content-only edits to files inside skills/ do NOT update the parent
|
// For each root:
|
||||||
// directory mtime on most filesystems, so we recursively walk to check
|
// 1. Creation/deletion and root directory mtime changes are tracked by fileChangedSince.
|
||||||
// individual file mtimes at any nesting depth.
|
// 2. Nested file create/delete/mtime changes are tracked by the skill file snapshot.
|
||||||
if skillFilesModifiedSince(skillsDir, cb.cachedAt) {
|
for _, root := range cb.skillRoots() {
|
||||||
|
if cb.fileChangedSince(root) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if skillFilesChangedSince(cb.skillRoots(), cb.skillFilesAtCache) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -310,28 +337,64 @@ func (cb *ContextBuilder) fileChangedSince(path string) bool {
|
||||||
// if the callback returned nil when its err parameter is non-nil.
|
// if the callback returned nil when its err parameter is non-nil.
|
||||||
var errWalkStop = errors.New("walk stop")
|
var errWalkStop = errors.New("walk stop")
|
||||||
|
|
||||||
// skillFilesModifiedSince recursively walks the skills directory and checks
|
// skillFilesChangedSince compares the current recursive skill file tree
|
||||||
// whether any file was modified after t. This catches content-only edits at
|
// against the cache-time snapshot. Any create/delete/mtime drift invalidates
|
||||||
// any nesting depth (e.g. skills/name/docs/extra.md) that don't update
|
// the cache.
|
||||||
// parent directory mtimes.
|
func skillFilesChangedSince(skillRoots []string, filesAtCache map[string]time.Time) bool {
|
||||||
func skillFilesModifiedSince(skillsDir string, t time.Time) bool {
|
// Defensive: if the snapshot was never initialized, force rebuild.
|
||||||
changed := false
|
if filesAtCache == nil {
|
||||||
err := filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error {
|
return true
|
||||||
if walkErr == nil && !d.IsDir() {
|
|
||||||
if info, statErr := os.Stat(path); statErr == nil && info.ModTime().After(t) {
|
|
||||||
changed = true
|
|
||||||
return errWalkStop // stop walking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
// errWalkStop is expected (early exit on first changed file).
|
|
||||||
// os.IsNotExist means the skills dir doesn't exist yet — not an error.
|
|
||||||
// Any other error is unexpected and worth logging.
|
|
||||||
if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) {
|
|
||||||
logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()})
|
|
||||||
}
|
}
|
||||||
return changed
|
|
||||||
|
// Check cached files still exist and keep the same mtime.
|
||||||
|
for path, cachedMtime := range filesAtCache {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
// A previously tracked file disappeared (or became inaccessible):
|
||||||
|
// either way, cached skill summary may now be stale.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !info.ModTime().Equal(cachedMtime) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check no new files appeared under any skill root.
|
||||||
|
changed := false
|
||||||
|
for _, root := range skillRoots {
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
// Treat unexpected walk errors as changed to avoid stale cache.
|
||||||
|
if !os.IsNotExist(walkErr) {
|
||||||
|
changed = true
|
||||||
|
return errWalkStop
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := filesAtCache[path]; !ok {
|
||||||
|
changed = true
|
||||||
|
return errWalkStop
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) {
|
||||||
|
logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||||
|
|
@ -467,10 +530,14 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
|
|
||||||
// Add current user message
|
// Add current user message
|
||||||
if strings.TrimSpace(currentMessage) != "" {
|
if strings.TrimSpace(currentMessage) != "" {
|
||||||
messages = append(messages, providers.Message{
|
msg := providers.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: currentMessage,
|
Content: currentMessage,
|
||||||
})
|
}
|
||||||
|
if len(media) > 0 {
|
||||||
|
msg.Media = media
|
||||||
|
}
|
||||||
|
messages = append(messages, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
|
|
@ -538,7 +605,60 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitized
|
// Second pass: ensure every assistant message with tool_calls has matching
|
||||||
|
// tool result messages following it. This is required by strict providers
|
||||||
|
// like DeepSeek that enforce: "An assistant message with 'tool_calls' must
|
||||||
|
// be followed by tool messages responding to each 'tool_call_id'."
|
||||||
|
final := make([]providers.Message, 0, len(sanitized))
|
||||||
|
for i := 0; i < len(sanitized); i++ {
|
||||||
|
msg := sanitized[i]
|
||||||
|
if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
|
||||||
|
// Collect expected tool_call IDs
|
||||||
|
expected := make(map[string]bool, len(msg.ToolCalls))
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
expected[tc.ID] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check following messages for matching tool results
|
||||||
|
toolMsgCount := 0
|
||||||
|
for j := i + 1; j < len(sanitized); j++ {
|
||||||
|
if sanitized[j].Role != "tool" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
toolMsgCount++
|
||||||
|
if _, exists := expected[sanitized[j].ToolCallID]; exists {
|
||||||
|
expected[sanitized[j].ToolCallID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If any tool_call_id is missing, drop this assistant message and its partial tool messages
|
||||||
|
allFound := true
|
||||||
|
for toolCallID, found := range expected {
|
||||||
|
if !found {
|
||||||
|
allFound = false
|
||||||
|
logger.DebugCF(
|
||||||
|
"agent",
|
||||||
|
"Dropping assistant message with incomplete tool results",
|
||||||
|
map[string]any{
|
||||||
|
"missing_tool_call_id": toolCallID,
|
||||||
|
"expected_count": len(expected),
|
||||||
|
"found_count": toolMsgCount,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allFound {
|
||||||
|
// Skip this assistant message and its tool messages
|
||||||
|
i += toolMsgCount
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final = append(final, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return final
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) AddToolResult(
|
func (cb *ContextBuilder) AddToolResult(
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,162 @@ Updated content.`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGlobalSkillFileContentChange verifies that modifying a global skill
|
||||||
|
// (~/.picoclaw/skills) invalidates the cached system prompt.
|
||||||
|
func TestGlobalSkillFileContentChange(t *testing.T) {
|
||||||
|
tmpHome := t.TempDir()
|
||||||
|
t.Setenv("HOME", tmpHome)
|
||||||
|
|
||||||
|
tmpDir := setupWorkspace(t, nil)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
globalSkillPath := filepath.Join(tmpHome, ".picoclaw", "skills", "global-skill", "SKILL.md")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(globalSkillPath), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v1 := `---
|
||||||
|
name: global-skill
|
||||||
|
description: global-v1
|
||||||
|
---
|
||||||
|
# Global Skill v1`
|
||||||
|
if err := os.WriteFile(globalSkillPath, []byte(v1), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
sp1 := cb.BuildSystemPromptWithCache()
|
||||||
|
if !strings.Contains(sp1, "global-v1") {
|
||||||
|
t.Fatal("expected initial prompt to contain global skill description")
|
||||||
|
}
|
||||||
|
|
||||||
|
v2 := `---
|
||||||
|
name: global-skill
|
||||||
|
description: global-v2
|
||||||
|
---
|
||||||
|
# Global Skill v2`
|
||||||
|
if err := os.WriteFile(globalSkillPath, []byte(v2), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
future := time.Now().Add(2 * time.Second)
|
||||||
|
if err := os.Chtimes(globalSkillPath, future, future); err != nil {
|
||||||
|
t.Fatalf("failed to update mtime for %s: %v", globalSkillPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.systemPromptMutex.RLock()
|
||||||
|
changed := cb.sourceFilesChangedLocked()
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("sourceFilesChangedLocked() should detect global skill file content change")
|
||||||
|
}
|
||||||
|
|
||||||
|
sp2 := cb.BuildSystemPromptWithCache()
|
||||||
|
if !strings.Contains(sp2, "global-v2") {
|
||||||
|
t.Error("rebuilt prompt should contain updated global skill description")
|
||||||
|
}
|
||||||
|
if sp1 == sp2 {
|
||||||
|
t.Error("cache should be invalidated when global skill file content changes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuiltinSkillFileContentChange verifies that modifying a builtin skill
|
||||||
|
// invalidates the cached system prompt.
|
||||||
|
func TestBuiltinSkillFileContentChange(t *testing.T) {
|
||||||
|
tmpHome := t.TempDir()
|
||||||
|
t.Setenv("HOME", tmpHome)
|
||||||
|
|
||||||
|
tmpDir := setupWorkspace(t, nil)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
builtinRoot := t.TempDir()
|
||||||
|
t.Setenv("PICOCLAW_BUILTIN_SKILLS", builtinRoot)
|
||||||
|
|
||||||
|
builtinSkillPath := filepath.Join(builtinRoot, "builtin-skill", "SKILL.md")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(builtinSkillPath), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v1 := `---
|
||||||
|
name: builtin-skill
|
||||||
|
description: builtin-v1
|
||||||
|
---
|
||||||
|
# Builtin Skill v1`
|
||||||
|
if err := os.WriteFile(builtinSkillPath, []byte(v1), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
sp1 := cb.BuildSystemPromptWithCache()
|
||||||
|
if !strings.Contains(sp1, "builtin-v1") {
|
||||||
|
t.Fatal("expected initial prompt to contain builtin skill description")
|
||||||
|
}
|
||||||
|
|
||||||
|
v2 := `---
|
||||||
|
name: builtin-skill
|
||||||
|
description: builtin-v2
|
||||||
|
---
|
||||||
|
# Builtin Skill v2`
|
||||||
|
if err := os.WriteFile(builtinSkillPath, []byte(v2), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
future := time.Now().Add(2 * time.Second)
|
||||||
|
if err := os.Chtimes(builtinSkillPath, future, future); err != nil {
|
||||||
|
t.Fatalf("failed to update mtime for %s: %v", builtinSkillPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.systemPromptMutex.RLock()
|
||||||
|
changed := cb.sourceFilesChangedLocked()
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("sourceFilesChangedLocked() should detect builtin skill file content change")
|
||||||
|
}
|
||||||
|
|
||||||
|
sp2 := cb.BuildSystemPromptWithCache()
|
||||||
|
if !strings.Contains(sp2, "builtin-v2") {
|
||||||
|
t.Error("rebuilt prompt should contain updated builtin skill description")
|
||||||
|
}
|
||||||
|
if sp1 == sp2 {
|
||||||
|
t.Error("cache should be invalidated when builtin skill file content changes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSkillFileDeletionInvalidatesCache verifies that deleting a nested skill
|
||||||
|
// file invalidates the cached system prompt.
|
||||||
|
func TestSkillFileDeletionInvalidatesCache(t *testing.T) {
|
||||||
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
|
"skills/delete-me/SKILL.md": `---
|
||||||
|
name: delete-me
|
||||||
|
description: delete-me-v1
|
||||||
|
---
|
||||||
|
# Delete Me`,
|
||||||
|
})
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
sp1 := cb.BuildSystemPromptWithCache()
|
||||||
|
if !strings.Contains(sp1, "delete-me-v1") {
|
||||||
|
t.Fatal("expected initial prompt to contain skill description")
|
||||||
|
}
|
||||||
|
|
||||||
|
skillPath := filepath.Join(tmpDir, "skills", "delete-me", "SKILL.md")
|
||||||
|
if err := os.Remove(skillPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.systemPromptMutex.RLock()
|
||||||
|
changed := cb.sourceFilesChangedLocked()
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("sourceFilesChangedLocked() should detect deleted skill file")
|
||||||
|
}
|
||||||
|
|
||||||
|
sp2 := cb.BuildSystemPromptWithCache()
|
||||||
|
if strings.Contains(sp2, "delete-me-v1") {
|
||||||
|
t.Error("rebuilt prompt should not contain deleted skill description")
|
||||||
|
}
|
||||||
|
if sp1 == sp2 {
|
||||||
|
t.Error("cache should be invalidated when skill file is deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines
|
// TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines
|
||||||
// can safely call BuildSystemPromptWithCache concurrently without producing
|
// can safely call BuildSystemPromptWithCache concurrently without producing
|
||||||
// empty results, panics, or data races.
|
// empty results, panics, or data races.
|
||||||
|
|
@ -404,11 +560,11 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
errs := make(chan string, goroutines*iterations)
|
errs := make(chan string, goroutines*iterations)
|
||||||
|
|
||||||
for g := 0; g < goroutines; g++ {
|
for g := range goroutines {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(id int) {
|
go func(id int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i := 0; i < iterations; i++ {
|
for i := range iterations {
|
||||||
result := cb.BuildSystemPromptWithCache()
|
result := cb.BuildSystemPromptWithCache()
|
||||||
if result == "" {
|
if result == "" {
|
||||||
errs <- "empty prompt returned"
|
errs <- "empty prompt returned"
|
||||||
|
|
|
||||||
|
|
@ -207,3 +207,77 @@ func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSanitizeHistoryForProvider_IncompleteToolResults tests the forward validation
|
||||||
|
// that ensures assistant messages with tool_calls have ALL matching tool results.
|
||||||
|
// This fixes the DeepSeek error: "An assistant message with 'tool_calls' must be
|
||||||
|
// followed by tool messages responding to each 'tool_call_id'."
|
||||||
|
func TestSanitizeHistoryForProvider_IncompleteToolResults(t *testing.T) {
|
||||||
|
// Assistant expects tool results for both A and B, but only A is present
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "do two things"),
|
||||||
|
assistantWithTools("A", "B"),
|
||||||
|
toolResult("A"),
|
||||||
|
// toolResult("B") is missing - this would cause DeepSeek to fail
|
||||||
|
msg("user", "next question"),
|
||||||
|
msg("assistant", "answer"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
// The assistant message with incomplete tool results should be dropped,
|
||||||
|
// along with its partial tool result. The remaining messages are:
|
||||||
|
// user ("do two things"), user ("next question"), assistant ("answer")
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "user", "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSanitizeHistoryForProvider_MissingAllToolResults tests the case where
|
||||||
|
// an assistant message has tool_calls but no tool results follow at all.
|
||||||
|
func TestSanitizeHistoryForProvider_MissingAllToolResults(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "do something"),
|
||||||
|
assistantWithTools("A"),
|
||||||
|
// No tool results at all
|
||||||
|
msg("user", "hello"),
|
||||||
|
msg("assistant", "hi"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
// The assistant message with no tool results should be dropped.
|
||||||
|
// Remaining: user ("do something"), user ("hello"), assistant ("hi")
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "user", "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSanitizeHistoryForProvider_PartialToolResultsInMiddle tests that
|
||||||
|
// incomplete tool results in the middle of a conversation are properly handled.
|
||||||
|
func TestSanitizeHistoryForProvider_PartialToolResultsInMiddle(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "first"),
|
||||||
|
assistantWithTools("A"),
|
||||||
|
toolResult("A"),
|
||||||
|
msg("assistant", "done"),
|
||||||
|
msg("user", "second"),
|
||||||
|
assistantWithTools("B", "C"),
|
||||||
|
toolResult("B"),
|
||||||
|
// toolResult("C") is missing
|
||||||
|
msg("user", "third"),
|
||||||
|
assistantWithTools("D"),
|
||||||
|
toolResult("D"),
|
||||||
|
msg("assistant", "all done"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
// First round is complete (user, assistant+tools, tool, assistant),
|
||||||
|
// second round is incomplete and dropped (assistant+tools, partial tool),
|
||||||
|
// third round is complete (user, assistant+tools, tool, assistant).
|
||||||
|
// Remaining: user, assistant, tool, assistant, user, user, assistant, tool, assistant
|
||||||
|
if len(result) != 9 {
|
||||||
|
t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "assistant", "tool", "assistant", "user", "user", "assistant", "tool", "assistant")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
@ -15,22 +18,33 @@ import (
|
||||||
// AgentInstance represents a fully configured agent with its own workspace,
|
// AgentInstance represents a fully configured agent with its own workspace,
|
||||||
// session manager, context builder, and tool registry.
|
// session manager, context builder, and tool registry.
|
||||||
type AgentInstance struct {
|
type AgentInstance struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Model string
|
Model string
|
||||||
Fallbacks []string
|
Fallbacks []string
|
||||||
Workspace string
|
Workspace string
|
||||||
MaxIterations int
|
MaxIterations int
|
||||||
MaxTokens int
|
MaxTokens int
|
||||||
Temperature float64
|
Temperature float64
|
||||||
ContextWindow int
|
ThinkingLevel ThinkingLevel
|
||||||
Provider providers.LLMProvider
|
ContextWindow int
|
||||||
Sessions *session.SessionManager
|
SummarizeMessageThreshold int
|
||||||
ContextBuilder *ContextBuilder
|
SummarizeTokenPercent int
|
||||||
Tools *tools.ToolRegistry
|
Provider providers.LLMProvider
|
||||||
Subagents *config.SubagentsConfig
|
Sessions *session.SessionManager
|
||||||
SkillsFilter []string
|
ContextBuilder *ContextBuilder
|
||||||
Candidates []providers.FallbackCandidate
|
Tools *tools.ToolRegistry
|
||||||
|
Subagents *config.SubagentsConfig
|
||||||
|
SkillsFilter []string
|
||||||
|
Candidates []providers.FallbackCandidate
|
||||||
|
|
||||||
|
// Router is non-nil when model routing is configured and the light model
|
||||||
|
// was successfully resolved. It scores each incoming message and decides
|
||||||
|
// whether to route to LightCandidates or stay with Candidates.
|
||||||
|
Router *routing.Router
|
||||||
|
// LightCandidates holds the resolved provider candidates for the light model.
|
||||||
|
// Pre-computed at agent creation to avoid repeated model_list lookups at runtime.
|
||||||
|
LightCandidates []providers.FallbackCandidate
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentInstance creates an agent instance from config.
|
// NewAgentInstance creates an agent instance from config.
|
||||||
|
|
@ -47,13 +61,37 @@ func NewAgentInstance(
|
||||||
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
|
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
|
||||||
|
|
||||||
restrict := defaults.RestrictToWorkspace
|
restrict := defaults.RestrictToWorkspace
|
||||||
|
readRestrict := restrict && !defaults.AllowReadOutsideWorkspace
|
||||||
|
|
||||||
|
// Compile path whitelist patterns from config.
|
||||||
|
allowReadPaths := compilePatterns(cfg.Tools.AllowReadPaths)
|
||||||
|
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
|
||||||
|
|
||||||
toolsRegistry := tools.NewToolRegistry()
|
toolsRegistry := tools.NewToolRegistry()
|
||||||
toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
|
|
||||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
|
if cfg.Tools.IsToolEnabled("read_file") {
|
||||||
toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
|
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, allowReadPaths))
|
||||||
toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg))
|
}
|
||||||
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
|
if cfg.Tools.IsToolEnabled("write_file") {
|
||||||
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
|
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("list_dir") {
|
||||||
|
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("exec") {
|
||||||
|
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
|
||||||
|
}
|
||||||
|
toolsRegistry.Register(execTool)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Tools.IsToolEnabled("edit_file") {
|
||||||
|
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("append_file") {
|
||||||
|
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
|
||||||
sessionsDir := filepath.Join(workspace, "sessions")
|
sessionsDir := filepath.Join(workspace, "sessions")
|
||||||
sessionsManager := session.NewSessionManager(sessionsDir)
|
sessionsManager := session.NewSessionManager(sessionsDir)
|
||||||
|
|
@ -87,30 +125,110 @@ func NewAgentInstance(
|
||||||
temperature = *defaults.Temperature
|
temperature = *defaults.Temperature
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var thinkingLevelStr string
|
||||||
|
if mc, err := cfg.GetModelConfig(model); err == nil {
|
||||||
|
thinkingLevelStr = mc.ThinkingLevel
|
||||||
|
}
|
||||||
|
thinkingLevel := parseThinkingLevel(thinkingLevelStr)
|
||||||
|
|
||||||
|
summarizeMessageThreshold := defaults.SummarizeMessageThreshold
|
||||||
|
if summarizeMessageThreshold == 0 {
|
||||||
|
summarizeMessageThreshold = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
summarizeTokenPercent := defaults.SummarizeTokenPercent
|
||||||
|
if summarizeTokenPercent == 0 {
|
||||||
|
summarizeTokenPercent = 75
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve fallback candidates
|
// Resolve fallback candidates
|
||||||
modelCfg := providers.ModelConfig{
|
modelCfg := providers.ModelConfig{
|
||||||
Primary: model,
|
Primary: model,
|
||||||
Fallbacks: fallbacks,
|
Fallbacks: fallbacks,
|
||||||
}
|
}
|
||||||
candidates := providers.ResolveCandidates(modelCfg, defaults.Provider)
|
resolveFromModelList := func(raw string) (string, bool) {
|
||||||
|
ensureProtocol := func(model string) string {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if model == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(model, "/") {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return "openai/" + model
|
||||||
|
}
|
||||||
|
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg != nil {
|
||||||
|
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
|
||||||
|
return ensureProtocol(mc.Model), true
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
|
||||||
|
if fullModel == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fullModel == raw {
|
||||||
|
return ensureProtocol(fullModel), true
|
||||||
|
}
|
||||||
|
_, modelID := providers.ExtractProtocol(fullModel)
|
||||||
|
if modelID == raw {
|
||||||
|
return ensureProtocol(fullModel), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
|
||||||
|
|
||||||
|
// Model routing setup: pre-resolve light model candidates at creation time
|
||||||
|
// to avoid repeated model_list lookups on every incoming message.
|
||||||
|
var router *routing.Router
|
||||||
|
var lightCandidates []providers.FallbackCandidate
|
||||||
|
if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" {
|
||||||
|
lightModelCfg := providers.ModelConfig{Primary: rc.LightModel}
|
||||||
|
resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList)
|
||||||
|
if len(resolved) > 0 {
|
||||||
|
router = routing.New(routing.RouterConfig{
|
||||||
|
LightModel: rc.LightModel,
|
||||||
|
Threshold: rc.Threshold,
|
||||||
|
})
|
||||||
|
lightCandidates = resolved
|
||||||
|
} else {
|
||||||
|
log.Printf("routing: light_model %q not found in model_list — routing disabled for agent %q",
|
||||||
|
rc.LightModel, agentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &AgentInstance{
|
return &AgentInstance{
|
||||||
ID: agentID,
|
ID: agentID,
|
||||||
Name: agentName,
|
Name: agentName,
|
||||||
Model: model,
|
Model: model,
|
||||||
Fallbacks: fallbacks,
|
Fallbacks: fallbacks,
|
||||||
Workspace: workspace,
|
Workspace: workspace,
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
MaxTokens: maxTokens,
|
MaxTokens: maxTokens,
|
||||||
Temperature: temperature,
|
Temperature: temperature,
|
||||||
ContextWindow: maxTokens,
|
ThinkingLevel: thinkingLevel,
|
||||||
Provider: provider,
|
ContextWindow: maxTokens,
|
||||||
Sessions: sessionsManager,
|
SummarizeMessageThreshold: summarizeMessageThreshold,
|
||||||
ContextBuilder: contextBuilder,
|
SummarizeTokenPercent: summarizeTokenPercent,
|
||||||
Tools: toolsRegistry,
|
Provider: provider,
|
||||||
Subagents: subagents,
|
Sessions: sessionsManager,
|
||||||
SkillsFilter: skillsFilter,
|
ContextBuilder: contextBuilder,
|
||||||
Candidates: candidates,
|
Tools: toolsRegistry,
|
||||||
|
Subagents: subagents,
|
||||||
|
SkillsFilter: skillsFilter,
|
||||||
|
Candidates: candidates,
|
||||||
|
Router: router,
|
||||||
|
LightCandidates: lightCandidates,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,12 +237,13 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD
|
||||||
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
||||||
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
||||||
}
|
}
|
||||||
|
// Use the configured default workspace (respects PICOCLAW_HOME)
|
||||||
if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
|
if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
|
||||||
return expandHome(defaults.Workspace)
|
return expandHome(defaults.Workspace)
|
||||||
}
|
}
|
||||||
home, _ := os.UserHomeDir()
|
// For named agents without explicit workspace, use default workspace with agent ID suffix
|
||||||
id := routing.NormalizeAgentID(agentCfg.ID)
|
id := routing.NormalizeAgentID(agentCfg.ID)
|
||||||
return filepath.Join(home, ".picoclaw", "workspace-"+id)
|
return filepath.Join(expandHome(defaults.Workspace), "..", "workspace-"+id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAgentModel resolves the primary model for an agent.
|
// resolveAgentModel resolves the primary model for an agent.
|
||||||
|
|
@ -143,6 +262,19 @@ func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentD
|
||||||
return defaults.ModelFallbacks
|
return defaults.ModelFallbacks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func compilePatterns(patterns []string) []*regexp.Regexp {
|
||||||
|
compiled := make([]*regexp.Regexp, 0, len(patterns))
|
||||||
|
for _, p := range patterns {
|
||||||
|
re, err := regexp.Compile(p)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: invalid path pattern %q: %v\n", p, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
compiled = append(compiled, re)
|
||||||
|
}
|
||||||
|
return compiled
|
||||||
|
}
|
||||||
|
|
||||||
func expandHome(path string) string {
|
func expandHome(path string) string {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return path
|
return path
|
||||||
|
|
|
||||||
|
|
@ -93,3 +93,70 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
|
||||||
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
|
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
aliasName string
|
||||||
|
modelName string
|
||||||
|
apiBase string
|
||||||
|
wantProvider string
|
||||||
|
wantModel string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "alias with provider prefix",
|
||||||
|
aliasName: "step-3.5-flash",
|
||||||
|
modelName: "openrouter/stepfun/step-3.5-flash:free",
|
||||||
|
apiBase: "https://openrouter.ai/api/v1",
|
||||||
|
wantProvider: "openrouter",
|
||||||
|
wantModel: "stepfun/step-3.5-flash:free",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "alias without provider prefix",
|
||||||
|
aliasName: "glm-5",
|
||||||
|
modelName: "glm-5",
|
||||||
|
apiBase: "https://api.z.ai/api/coding/paas/v4",
|
||||||
|
wantProvider: "openai",
|
||||||
|
wantModel: "glm-5",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: tt.aliasName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: tt.aliasName,
|
||||||
|
Model: tt.modelName,
|
||||||
|
APIBase: tt.apiBase,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := &mockProvider{}
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
|
if len(agent.Candidates) != 1 {
|
||||||
|
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Provider != tt.wantProvider {
|
||||||
|
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, tt.wantProvider)
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Model != tt.wantModel {
|
||||||
|
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, tt.wantModel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
1123
pkg/agent/loop.go
1123
pkg/agent/loop.go
File diff suppressed because it is too large
Load diff
122
pkg/agent/loop_media.go
Normal file
122
pkg/agent/loop_media.go
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/h2non/filetype"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolveMediaRefs replaces media:// refs in message Media fields with base64 data URLs.
|
||||||
|
// Uses streaming base64 encoding (file handle → encoder → buffer) to avoid holding
|
||||||
|
// both raw bytes and encoded string in memory simultaneously.
|
||||||
|
// Returns a new slice; original messages are not mutated.
|
||||||
|
func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message {
|
||||||
|
if store == nil {
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]providers.Message, len(messages))
|
||||||
|
copy(result, messages)
|
||||||
|
|
||||||
|
for i, m := range result {
|
||||||
|
if len(m.Media) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := make([]string, 0, len(m.Media))
|
||||||
|
for _, ref := range m.Media {
|
||||||
|
if !strings.HasPrefix(ref, "media://") {
|
||||||
|
resolved = append(resolved, ref)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath, meta, err := store.ResolveWithMeta(ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to resolve media ref", map[string]any{
|
||||||
|
"ref": ref,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(localPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to stat media file", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.Size() > int64(maxSize) {
|
||||||
|
logger.WarnCF("agent", "Media file too large, skipping", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"size": info.Size(),
|
||||||
|
"max_size": maxSize,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine MIME type: prefer metadata, fallback to magic-bytes detection
|
||||||
|
mime := meta.ContentType
|
||||||
|
if mime == "" {
|
||||||
|
kind, ftErr := filetype.MatchFile(localPath)
|
||||||
|
if ftErr != nil || kind == filetype.Unknown {
|
||||||
|
logger.WarnCF("agent", "Unknown media type, skipping", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mime = kind.MIME.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaming base64: open file → base64 encoder → buffer
|
||||||
|
// Peak memory: ~1.33x file size (buffer only, no raw bytes copy)
|
||||||
|
f, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to open media file", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := "data:" + mime + ";base64,"
|
||||||
|
encodedLen := base64.StdEncoding.EncodedLen(int(info.Size()))
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.Grow(len(prefix) + encodedLen)
|
||||||
|
buf.WriteString(prefix)
|
||||||
|
|
||||||
|
encoder := base64.NewEncoder(base64.StdEncoding, &buf)
|
||||||
|
if _, err := io.Copy(encoder, f); err != nil {
|
||||||
|
f.Close()
|
||||||
|
logger.WarnCF("agent", "Failed to encode media file", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
encoder.Close()
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
resolved = append(resolved, buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
result[i].Media = resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
@ -5,25 +5,40 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRecordLastChannel(t *testing.T) {
|
type fakeChannel struct{ id string }
|
||||||
// Create temp workspace
|
|
||||||
|
func (f *fakeChannel) Name() string { return "fake" }
|
||||||
|
func (f *fakeChannel) Start(ctx context.Context) error { return nil }
|
||||||
|
func (f *fakeChannel) Stop(ctx context.Context) error { return nil }
|
||||||
|
func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil }
|
||||||
|
func (f *fakeChannel) IsRunning() bool { return true }
|
||||||
|
func (f *fakeChannel) IsAllowed(string) bool { return true }
|
||||||
|
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
|
||||||
|
func (f *fakeChannel) ReasoningChannelID() string { return f.id }
|
||||||
|
|
||||||
|
func newTestAgentLoop(
|
||||||
|
t *testing.T,
|
||||||
|
) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) {
|
||||||
|
t.Helper()
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
cfg = &config.Config{
|
||||||
|
|
||||||
// Create test config
|
|
||||||
cfg := &config.Config{
|
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
|
@ -33,74 +48,43 @@ func TestRecordLastChannel(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
msgBus = bus.NewMessageBus()
|
||||||
|
provider = &mockProvider{}
|
||||||
|
al = NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) }
|
||||||
|
}
|
||||||
|
|
||||||
// Create agent loop
|
func TestRecordLastChannel(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)
|
||||||
provider := &mockProvider{}
|
defer cleanup()
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
|
||||||
|
|
||||||
// Test RecordLastChannel
|
|
||||||
testChannel := "test-channel"
|
testChannel := "test-channel"
|
||||||
err = al.RecordLastChannel(testChannel)
|
if err := al.RecordLastChannel(testChannel); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("RecordLastChannel failed: %v", err)
|
t.Fatalf("RecordLastChannel failed: %v", err)
|
||||||
}
|
}
|
||||||
|
if got := al.state.GetLastChannel(); got != testChannel {
|
||||||
// Verify channel was saved
|
t.Errorf("Expected channel '%s', got '%s'", testChannel, got)
|
||||||
lastChannel := al.state.GetLastChannel()
|
|
||||||
if lastChannel != testChannel {
|
|
||||||
t.Errorf("Expected channel '%s', got '%s'", testChannel, lastChannel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify persistence by creating a new agent loop
|
|
||||||
al2 := NewAgentLoop(cfg, msgBus, provider)
|
al2 := NewAgentLoop(cfg, msgBus, provider)
|
||||||
if al2.state.GetLastChannel() != testChannel {
|
if got := al2.state.GetLastChannel(); got != testChannel {
|
||||||
t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, al2.state.GetLastChannel())
|
t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecordLastChatID(t *testing.T) {
|
func TestRecordLastChatID(t *testing.T) {
|
||||||
// Create temp workspace
|
al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
defer cleanup()
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(tmpDir)
|
|
||||||
|
|
||||||
// Create test config
|
|
||||||
cfg := &config.Config{
|
|
||||||
Agents: config.AgentsConfig{
|
|
||||||
Defaults: config.AgentDefaults{
|
|
||||||
Workspace: tmpDir,
|
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
|
||||||
MaxToolIterations: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create agent loop
|
|
||||||
msgBus := bus.NewMessageBus()
|
|
||||||
provider := &mockProvider{}
|
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
|
||||||
|
|
||||||
// Test RecordLastChatID
|
|
||||||
testChatID := "test-chat-id-123"
|
testChatID := "test-chat-id-123"
|
||||||
err = al.RecordLastChatID(testChatID)
|
if err := al.RecordLastChatID(testChatID); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("RecordLastChatID failed: %v", err)
|
t.Fatalf("RecordLastChatID failed: %v", err)
|
||||||
}
|
}
|
||||||
|
if got := al.state.GetLastChatID(); got != testChatID {
|
||||||
// Verify chat ID was saved
|
t.Errorf("Expected chat ID '%s', got '%s'", testChatID, got)
|
||||||
lastChatID := al.state.GetLastChatID()
|
|
||||||
if lastChatID != testChatID {
|
|
||||||
t.Errorf("Expected chat ID '%s', got '%s'", testChatID, lastChatID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify persistence by creating a new agent loop
|
|
||||||
al2 := NewAgentLoop(cfg, msgBus, provider)
|
al2 := NewAgentLoop(cfg, msgBus, provider)
|
||||||
if al2.state.GetLastChatID() != testChatID {
|
if got := al2.state.GetLastChatID(); got != testChatID {
|
||||||
t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, al2.state.GetLastChatID())
|
t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,47 +159,27 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
|
||||||
toolsList := toolsInfo["names"].([]string)
|
toolsList := toolsInfo["names"].([]string)
|
||||||
|
|
||||||
// Check that our custom tool name is in the list
|
// Check that our custom tool name is in the list
|
||||||
found := false
|
found := slices.Contains(toolsList, "mock_custom")
|
||||||
for _, name := range toolsList {
|
|
||||||
if name == "mock_custom" {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
if !found {
|
||||||
t.Error("Expected custom tool to be registered")
|
t.Error("Expected custom tool to be registered")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestToolContext_Updates verifies tool context is updated with channel/chatID
|
// TestToolContext_Updates verifies tool context helpers work correctly
|
||||||
func TestToolContext_Updates(t *testing.T) {
|
func TestToolContext_Updates(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
ctx := tools.WithToolContext(context.Background(), "telegram", "chat-42")
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(tmpDir)
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
if got := tools.ToolChannel(ctx); got != "telegram" {
|
||||||
Agents: config.AgentsConfig{
|
t.Errorf("expected channel 'telegram', got %q", got)
|
||||||
Defaults: config.AgentDefaults{
|
}
|
||||||
Workspace: tmpDir,
|
if got := tools.ToolChatID(ctx); got != "chat-42" {
|
||||||
Model: "test-model",
|
t.Errorf("expected chatID 'chat-42', got %q", got)
|
||||||
MaxTokens: 4096,
|
|
||||||
MaxToolIterations: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
// Empty context returns empty strings
|
||||||
provider := &simpleMockProvider{response: "OK"}
|
if got := tools.ToolChannel(context.Background()); got != "" {
|
||||||
_ = NewAgentLoop(cfg, msgBus, provider)
|
t.Errorf("expected empty channel from bare context, got %q", got)
|
||||||
|
}
|
||||||
// Verify that ContextualTool interface is defined and can be implemented
|
|
||||||
// This test validates the interface contract exists
|
|
||||||
ctxTool := &mockContextualTool{}
|
|
||||||
|
|
||||||
// Verify the tool implements the interface correctly
|
|
||||||
var _ tools.ContextualTool = ctxTool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved
|
// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved
|
||||||
|
|
@ -250,13 +214,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
|
||||||
toolsList := toolsInfo["names"].([]string)
|
toolsList := toolsInfo["names"].([]string)
|
||||||
|
|
||||||
// Check that our custom tool name is in the list
|
// Check that our custom tool name is in the list
|
||||||
found := false
|
found := slices.Contains(toolsList, "mock_custom")
|
||||||
for _, name := range toolsList {
|
|
||||||
if name == "mock_custom" {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
if !found {
|
||||||
t.Error("Expected custom tool to be registered")
|
t.Error("Expected custom tool to be registered")
|
||||||
}
|
}
|
||||||
|
|
@ -270,16 +228,11 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := config.DefaultConfig()
|
||||||
Agents: config.AgentsConfig{
|
cfg.Agents.Defaults.Workspace = tmpDir
|
||||||
Defaults: config.AgentDefaults{
|
cfg.Agents.Defaults.Model = "test-model"
|
||||||
Workspace: tmpDir,
|
cfg.Agents.Defaults.MaxTokens = 4096
|
||||||
Model: "test-model",
|
cfg.Agents.Defaults.MaxToolIterations = 10
|
||||||
MaxTokens: 4096,
|
|
||||||
MaxToolIterations: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &mockProvider{}
|
provider := &mockProvider{}
|
||||||
|
|
@ -366,6 +319,29 @@ func (m *simpleMockProvider) GetDefaultModel() string {
|
||||||
return "mock-model"
|
return "mock-model"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type countingMockProvider struct {
|
||||||
|
response string
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countingMockProvider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []providers.Message,
|
||||||
|
tools []providers.ToolDefinition,
|
||||||
|
model string,
|
||||||
|
opts map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
m.calls++
|
||||||
|
return &providers.LLMResponse{
|
||||||
|
Content: m.response,
|
||||||
|
ToolCalls: []providers.ToolCall{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countingMockProvider) GetDefaultModel() string {
|
||||||
|
return "counting-mock-model"
|
||||||
|
}
|
||||||
|
|
||||||
// mockCustomTool is a simple mock tool for registration testing
|
// mockCustomTool is a simple mock tool for registration testing
|
||||||
type mockCustomTool struct{}
|
type mockCustomTool struct{}
|
||||||
|
|
||||||
|
|
@ -388,36 +364,6 @@ func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tool
|
||||||
return tools.SilentResult("Custom tool executed")
|
return tools.SilentResult("Custom tool executed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// mockContextualTool tracks context updates
|
|
||||||
type mockContextualTool struct {
|
|
||||||
lastChannel string
|
|
||||||
lastChatID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Name() string {
|
|
||||||
return "mock_contextual"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Description() string {
|
|
||||||
return "Mock contextual tool"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Parameters() map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
|
||||||
return tools.SilentResult("Contextual tool executed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) SetContext(channel, chatID string) {
|
|
||||||
m.lastChannel = channel
|
|
||||||
m.lastChatID = chatID
|
|
||||||
}
|
|
||||||
|
|
||||||
// testHelper executes a message and returns the response
|
// testHelper executes a message and returns the response
|
||||||
type testHelper struct {
|
type testHelper struct {
|
||||||
al *AgentLoop
|
al *AgentLoop
|
||||||
|
|
@ -437,6 +383,198 @@ func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, ms
|
||||||
|
|
||||||
const responseTimeout = 3 * time.Second
|
const responseTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
func TestProcessMessage_UsesRouteSessionKey(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &simpleMockProvider{response: "ok"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
msg := bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "hello",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
route := al.registry.ResolveRoute(routing.RouteInput{
|
||||||
|
Channel: msg.Channel,
|
||||||
|
Peer: extractPeer(msg),
|
||||||
|
})
|
||||||
|
sessionKey := route.SessionKey
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
if defaultAgent == nil {
|
||||||
|
t.Fatal("No default agent found")
|
||||||
|
}
|
||||||
|
|
||||||
|
helper := testHelper{al: al}
|
||||||
|
_ = helper.executeAndGetResponse(t, context.Background(), msg)
|
||||||
|
|
||||||
|
history := defaultAgent.Sessions.GetHistory(sessionKey)
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("expected session history len=2, got %d", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Role != "user" || history[0].Content != "hello" {
|
||||||
|
t.Fatalf("unexpected first message in session: %+v", history[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_CommandOutcomes(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Session: config.SessionConfig{
|
||||||
|
DMScope: "per-channel-peer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &countingMockProvider{response: "LLM reply"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
|
baseMsg := bus.InboundMessage{
|
||||||
|
Channel: "whatsapp",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: baseMsg.Channel,
|
||||||
|
SenderID: baseMsg.SenderID,
|
||||||
|
ChatID: baseMsg.ChatID,
|
||||||
|
Content: "/show channel",
|
||||||
|
Peer: baseMsg.Peer,
|
||||||
|
})
|
||||||
|
if showResp != "Current Channel: whatsapp" {
|
||||||
|
t.Fatalf("unexpected /show reply: %q", showResp)
|
||||||
|
}
|
||||||
|
if provider.calls != 0 {
|
||||||
|
t.Fatalf("LLM should not be called for handled command, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
fooResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: baseMsg.Channel,
|
||||||
|
SenderID: baseMsg.SenderID,
|
||||||
|
ChatID: baseMsg.ChatID,
|
||||||
|
Content: "/foo",
|
||||||
|
Peer: baseMsg.Peer,
|
||||||
|
})
|
||||||
|
if fooResp != "LLM reply" {
|
||||||
|
t.Fatalf("unexpected /foo reply: %q", fooResp)
|
||||||
|
}
|
||||||
|
if provider.calls != 1 {
|
||||||
|
t.Fatalf("LLM should be called exactly once after /foo passthrough, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: baseMsg.Channel,
|
||||||
|
SenderID: baseMsg.SenderID,
|
||||||
|
ChatID: baseMsg.ChatID,
|
||||||
|
Content: "/new",
|
||||||
|
Peer: baseMsg.Peer,
|
||||||
|
})
|
||||||
|
if newResp != "LLM reply" {
|
||||||
|
t.Fatalf("unexpected /new reply: %q", newResp)
|
||||||
|
}
|
||||||
|
if provider.calls != 2 {
|
||||||
|
t.Fatalf("LLM should be called for passthrough /new command, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Provider: "openai",
|
||||||
|
Model: "before-switch",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &countingMockProvider{response: "LLM reply"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
|
switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "/switch model to after-switch",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !strings.Contains(switchResp, "Switched model from before-switch to after-switch") {
|
||||||
|
t.Fatalf("unexpected /switch reply: %q", switchResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "/show model",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !strings.Contains(showResp, "Current Model: after-switch (Provider: openai)") {
|
||||||
|
t.Fatalf("unexpected /show model reply after switch: %q", showResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider.calls != 0 {
|
||||||
|
t.Fatalf("LLM should not be called for /switch and /show, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
|
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
|
||||||
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
|
@ -631,3 +769,350 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
||||||
t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
|
t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
|
||||||
|
chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create channel manager: %v", err)
|
||||||
|
}
|
||||||
|
for name, id := range map[string]string{
|
||||||
|
"whatsapp": "rid-whatsapp",
|
||||||
|
"telegram": "rid-telegram",
|
||||||
|
"feishu": "rid-feishu",
|
||||||
|
"discord": "rid-discord",
|
||||||
|
"maixcam": "rid-maixcam",
|
||||||
|
"qq": "rid-qq",
|
||||||
|
"dingtalk": "rid-dingtalk",
|
||||||
|
"slack": "rid-slack",
|
||||||
|
"line": "rid-line",
|
||||||
|
"onebot": "rid-onebot",
|
||||||
|
"wecom": "rid-wecom",
|
||||||
|
"wecom_app": "rid-wecom-app",
|
||||||
|
} {
|
||||||
|
chManager.RegisterChannel(name, &fakeChannel{id: id})
|
||||||
|
}
|
||||||
|
al.SetChannelManager(chManager)
|
||||||
|
tests := []struct {
|
||||||
|
channel string
|
||||||
|
wantID string
|
||||||
|
}{
|
||||||
|
{channel: "whatsapp", wantID: "rid-whatsapp"},
|
||||||
|
{channel: "telegram", wantID: "rid-telegram"},
|
||||||
|
{channel: "feishu", wantID: "rid-feishu"},
|
||||||
|
{channel: "discord", wantID: "rid-discord"},
|
||||||
|
{channel: "maixcam", wantID: "rid-maixcam"},
|
||||||
|
{channel: "qq", wantID: "rid-qq"},
|
||||||
|
{channel: "dingtalk", wantID: "rid-dingtalk"},
|
||||||
|
{channel: "slack", wantID: "rid-slack"},
|
||||||
|
{channel: "line", wantID: "rid-line"},
|
||||||
|
{channel: "onebot", wantID: "rid-onebot"},
|
||||||
|
{channel: "wecom", wantID: "rid-wecom"},
|
||||||
|
{channel: "wecom_app", wantID: "rid-wecom-app"},
|
||||||
|
{channel: "unknown", wantID: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.channel, func(t *testing.T) {
|
||||||
|
got := al.targetReasoningChannelID(tt.channel)
|
||||||
|
if got != tt.wantID {
|
||||||
|
t.Fatalf("targetReasoningChannelID(%q) = %q, want %q", tt.channel, got, tt.wantID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleReasoning(t *testing.T) {
|
||||||
|
newLoop := func(t *testing.T) (*AgentLoop, *bus.MessageBus) {
|
||||||
|
t.Helper()
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
return NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("skips when any required field is empty", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
al.handleReasoning(context.Background(), "reasoning", "telegram", "")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if msg, ok := msgBus.SubscribeOutbound(ctx); ok {
|
||||||
|
t.Fatalf("expected no outbound message, got %+v", msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("publishes one message for non telegram", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
msg, ok := msgBus.SubscribeOutbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected an outbound message")
|
||||||
|
}
|
||||||
|
if msg.Channel != "slack" || msg.ChatID != "channel-1" || msg.Content != "hello reasoning" {
|
||||||
|
t.Fatalf("unexpected outbound message: %+v", msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("publishes one message for telegram", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
reasoning := "hello telegram reasoning"
|
||||||
|
al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
msg, ok := msgBus.SubscribeOutbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected outbound message")
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Channel != "telegram" {
|
||||||
|
t.Fatalf("expected telegram channel message, got %+v", msg)
|
||||||
|
}
|
||||||
|
if msg.ChatID != "tg-chat" {
|
||||||
|
t.Fatalf("expected chatID tg-chat, got %+v", msg)
|
||||||
|
}
|
||||||
|
if msg.Content != reasoning {
|
||||||
|
t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("expired ctx", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
reasoning := "hello telegram reasoning"
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
al.handleReasoning(ctx, reasoning, "telegram", "tg-chat")
|
||||||
|
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
msg, ok := msgBus.SubscribeOutbound(ctx)
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected no outbound message, got %+v", msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns promptly when bus is full", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
|
||||||
|
// Fill the outbound bus buffer until a publish would block.
|
||||||
|
// Use a short timeout to detect when the buffer is full,
|
||||||
|
// rather than hardcoding the buffer size.
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
fillCtx, fillCancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
err := msgBus.PublishOutbound(fillCtx, bus.OutboundMessage{
|
||||||
|
Channel: "filler",
|
||||||
|
ChatID: "filler",
|
||||||
|
Content: fmt.Sprintf("filler-%d", i),
|
||||||
|
})
|
||||||
|
fillCancel()
|
||||||
|
if err != nil {
|
||||||
|
// Buffer is full (timed out trying to send).
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a short-deadline parent context to bound the test.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
al.handleReasoning(ctx, "should timeout", "slack", "channel-full")
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// handleReasoning uses a 5s internal timeout, but the parent ctx
|
||||||
|
// expires in 500ms. It should return within ~500ms, not 5s.
|
||||||
|
if elapsed > 2*time.Second {
|
||||||
|
t.Fatalf("handleReasoning blocked too long (%v); expected prompt return", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain the bus and verify the reasoning message was NOT published
|
||||||
|
// (it should have been dropped due to timeout).
|
||||||
|
drainCtx, drainCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer drainCancel()
|
||||||
|
foundReasoning := false
|
||||||
|
for {
|
||||||
|
msg, ok := msgBus.SubscribeOutbound(drainCtx)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if msg.Content == "should timeout" {
|
||||||
|
foundReasoning = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if foundReasoning {
|
||||||
|
t.Fatal("expected reasoning message to be dropped when bus is full, but it was published")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) {
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a minimal valid PNG (8-byte header is enough for filetype detection)
|
||||||
|
pngPath := filepath.Join(dir, "test.png")
|
||||||
|
// PNG magic: 0x89 P N G \r \n 0x1A \n + minimal IHDR
|
||||||
|
pngHeader := []byte{
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||||
|
0x00, 0x00, 0x00, 0x0D, // IHDR length
|
||||||
|
0x49, 0x48, 0x44, 0x52, // "IHDR"
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, // 1x1 RGB
|
||||||
|
0x00, 0x00, 0x00, // no interlace
|
||||||
|
0x90, 0x77, 0x53, 0xDE, // CRC
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(pngPath, pngHeader, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ref, err := store.Store(pngPath, media.MediaMeta{}, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := []providers.Message{
|
||||||
|
{Role: "user", Content: "describe this", Media: []string{ref}},
|
||||||
|
}
|
||||||
|
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||||
|
|
||||||
|
if len(result[0].Media) != 1 {
|
||||||
|
t.Fatalf("expected 1 resolved media, got %d", len(result[0].Media))
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") {
|
||||||
|
t.Fatalf("expected data:image/png;base64, prefix, got %q", result[0].Media[0][:40])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) {
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
bigPath := filepath.Join(dir, "big.png")
|
||||||
|
// Write PNG header + padding to exceed limit
|
||||||
|
data := make([]byte, 1024+1) // 1KB + 1 byte
|
||||||
|
copy(data, []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})
|
||||||
|
if err := os.WriteFile(bigPath, data, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ref, _ := store.Store(bigPath, media.MediaMeta{}, "test")
|
||||||
|
|
||||||
|
messages := []providers.Message{
|
||||||
|
{Role: "user", Content: "hi", Media: []string{ref}},
|
||||||
|
}
|
||||||
|
// Use a tiny limit (1KB) so the file is oversized
|
||||||
|
result := resolveMediaRefs(messages, store, 1024)
|
||||||
|
|
||||||
|
if len(result[0].Media) != 0 {
|
||||||
|
t.Fatalf("expected 0 media (oversized), got %d", len(result[0].Media))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMediaRefs_SkipsUnknownType(t *testing.T) {
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
txtPath := filepath.Join(dir, "readme.txt")
|
||||||
|
if err := os.WriteFile(txtPath, []byte("hello world"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ref, _ := store.Store(txtPath, media.MediaMeta{}, "test")
|
||||||
|
|
||||||
|
messages := []providers.Message{
|
||||||
|
{Role: "user", Content: "hi", Media: []string{ref}},
|
||||||
|
}
|
||||||
|
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||||
|
|
||||||
|
if len(result[0].Media) != 0 {
|
||||||
|
t.Fatalf("expected 0 media (unknown type), got %d", len(result[0].Media))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMediaRefs_PassesThroughNonMediaRefs(t *testing.T) {
|
||||||
|
messages := []providers.Message{
|
||||||
|
{Role: "user", Content: "hi", Media: []string{"https://example.com/img.png"}},
|
||||||
|
}
|
||||||
|
result := resolveMediaRefs(messages, nil, config.DefaultMaxMediaSize)
|
||||||
|
|
||||||
|
if len(result[0].Media) != 1 || result[0].Media[0] != "https://example.com/img.png" {
|
||||||
|
t.Fatalf("expected passthrough of non-media:// URL, got %v", result[0].Media)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMediaRefs_DoesNotMutateOriginal(t *testing.T) {
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
dir := t.TempDir()
|
||||||
|
pngPath := filepath.Join(dir, "test.png")
|
||||||
|
pngHeader := []byte{
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||||
|
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02,
|
||||||
|
0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE,
|
||||||
|
}
|
||||||
|
os.WriteFile(pngPath, pngHeader, 0o644)
|
||||||
|
ref, _ := store.Store(pngPath, media.MediaMeta{}, "test")
|
||||||
|
|
||||||
|
original := []providers.Message{
|
||||||
|
{Role: "user", Content: "hi", Media: []string{ref}},
|
||||||
|
}
|
||||||
|
originalRef := original[0].Media[0]
|
||||||
|
|
||||||
|
resolveMediaRefs(original, store, config.DefaultMaxMediaSize)
|
||||||
|
|
||||||
|
if original[0].Media[0] != originalRef {
|
||||||
|
t.Fatal("resolveMediaRefs mutated original message slice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) {
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// File with JPEG content but stored with explicit content type
|
||||||
|
jpegPath := filepath.Join(dir, "photo")
|
||||||
|
jpegHeader := []byte{0xFF, 0xD8, 0xFF, 0xE0} // JPEG magic bytes
|
||||||
|
os.WriteFile(jpegPath, jpegHeader, 0o644)
|
||||||
|
ref, _ := store.Store(jpegPath, media.MediaMeta{ContentType: "image/jpeg"}, "test")
|
||||||
|
|
||||||
|
messages := []providers.Message{
|
||||||
|
{Role: "user", Content: "hi", Media: []string{ref}},
|
||||||
|
}
|
||||||
|
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||||
|
|
||||||
|
if len(result[0].Media) != 1 {
|
||||||
|
t.Fatalf("expected 1 media, got %d", len(result[0].Media))
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(result[0].Media[0], "data:image/jpeg;base64,") {
|
||||||
|
t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MemoryStore manages persistent memory for the agent.
|
// MemoryStore manages persistent memory for the agent.
|
||||||
|
|
@ -58,7 +60,9 @@ func (ms *MemoryStore) ReadLongTerm() string {
|
||||||
|
|
||||||
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
|
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
|
||||||
func (ms *MemoryStore) WriteLongTerm(content string) error {
|
func (ms *MemoryStore) WriteLongTerm(content string) error {
|
||||||
return os.WriteFile(ms.memoryFile, []byte(content), 0o644)
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
// Using 0o600 (owner read/write only) for secure default permissions.
|
||||||
|
return fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadToday reads today's daily note.
|
// ReadToday reads today's daily note.
|
||||||
|
|
@ -78,7 +82,9 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
||||||
|
|
||||||
// Ensure month directory exists
|
// Ensure month directory exists
|
||||||
monthDir := filepath.Dir(todayFile)
|
monthDir := filepath.Dir(todayFile)
|
||||||
os.MkdirAll(monthDir, 0o755)
|
if err := os.MkdirAll(monthDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
var existingContent string
|
var existingContent string
|
||||||
if data, err := os.ReadFile(todayFile); err == nil {
|
if data, err := os.ReadFile(todayFile); err == nil {
|
||||||
|
|
@ -95,7 +101,8 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
||||||
newContent = existingContent + "\n" + content
|
newContent = existingContent + "\n" + content
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(todayFile, []byte(newContent), 0o644)
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRecentDailyNotes returns daily notes from the last N days.
|
// GetRecentDailyNotes returns daily notes from the last N days.
|
||||||
|
|
@ -104,7 +111,7 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
first := true
|
first := true
|
||||||
|
|
||||||
for i := 0; i < days; i++ {
|
for i := range days {
|
||||||
date := time.Now().AddDate(0, 0, -i)
|
date := time.Now().AddDate(0, 0, -i)
|
||||||
dateStr := date.Format("20060102") // YYYYMMDD
|
dateStr := date.Format("20060102") // YYYYMMDD
|
||||||
monthDir := dateStr[:6] // YYYYMM
|
monthDir := dateStr[:6] // YYYYMM
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentRegistry manages multiple agent instances and routes messages to them.
|
// AgentRegistry manages multiple agent instances and routes messages to them.
|
||||||
|
|
@ -100,6 +101,19 @@ func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bo
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForEachTool calls fn for every tool registered under the given name
|
||||||
|
// across all agents. This is useful for propagating dependencies (e.g.
|
||||||
|
// MediaStore) to tools after registry construction.
|
||||||
|
func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
for _, agent := range r.agents {
|
||||||
|
if t, ok := agent.Tools.Get(name); ok {
|
||||||
|
fn(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetDefaultAgent returns the default agent instance.
|
// GetDefaultAgent returns the default agent instance.
|
||||||
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
|
||||||
39
pkg/agent/thinking.go
Normal file
39
pkg/agent/thinking.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// ThinkingLevel controls how the provider sends thinking parameters.
|
||||||
|
//
|
||||||
|
// - "adaptive": sends {thinking: {type: "adaptive"}} + output_config.effort (Claude 4.6+)
|
||||||
|
// - "low"/"medium"/"high"/"xhigh": sends {thinking: {type: "enabled", budget_tokens: N}} (all models)
|
||||||
|
// - "off": disables thinking
|
||||||
|
type ThinkingLevel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ThinkingOff ThinkingLevel = "off"
|
||||||
|
ThinkingLow ThinkingLevel = "low"
|
||||||
|
ThinkingMedium ThinkingLevel = "medium"
|
||||||
|
ThinkingHigh ThinkingLevel = "high"
|
||||||
|
ThinkingXHigh ThinkingLevel = "xhigh"
|
||||||
|
ThinkingAdaptive ThinkingLevel = "adaptive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseThinkingLevel normalizes a config string to a ThinkingLevel.
|
||||||
|
// Case-insensitive and whitespace-tolerant for user-facing config values.
|
||||||
|
// Returns ThinkingOff for unknown or empty values.
|
||||||
|
func parseThinkingLevel(level string) ThinkingLevel {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||||
|
case "adaptive":
|
||||||
|
return ThinkingAdaptive
|
||||||
|
case "low":
|
||||||
|
return ThinkingLow
|
||||||
|
case "medium":
|
||||||
|
return ThinkingMedium
|
||||||
|
case "high":
|
||||||
|
return ThinkingHigh
|
||||||
|
case "xhigh":
|
||||||
|
return ThinkingXHigh
|
||||||
|
default:
|
||||||
|
return ThinkingOff
|
||||||
|
}
|
||||||
|
}
|
||||||
35
pkg/agent/thinking_test.go
Normal file
35
pkg/agent/thinking_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseThinkingLevel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want ThinkingLevel
|
||||||
|
}{
|
||||||
|
{"off", "off", ThinkingOff},
|
||||||
|
{"empty", "", ThinkingOff},
|
||||||
|
{"low", "low", ThinkingLow},
|
||||||
|
{"medium", "medium", ThinkingMedium},
|
||||||
|
{"high", "high", ThinkingHigh},
|
||||||
|
{"xhigh", "xhigh", ThinkingXHigh},
|
||||||
|
{"adaptive", "adaptive", ThinkingAdaptive},
|
||||||
|
{"unknown", "unknown", ThinkingOff},
|
||||||
|
// Case-insensitive and whitespace-tolerant
|
||||||
|
{"upper_Medium", "Medium", ThinkingMedium},
|
||||||
|
{"upper_HIGH", "HIGH", ThinkingHigh},
|
||||||
|
{"mixed_Adaptive", "Adaptive", ThinkingAdaptive},
|
||||||
|
{"leading_space", " high", ThinkingHigh},
|
||||||
|
{"trailing_space", "low ", ThinkingLow},
|
||||||
|
{"both_spaces", " medium ", ThinkingMedium},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := parseThinkingLevel(tt.input); got != tt.want {
|
||||||
|
t.Errorf("parseThinkingLevel(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
71
pkg/auth/anthropic_usage.go
Normal file
71
pkg/auth/anthropic_usage.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
anthropicBetaHeader = "oauth-2025-04-20"
|
||||||
|
anthropicAPIVersion = "2023-06-01"
|
||||||
|
)
|
||||||
|
|
||||||
|
// anthropicUsageURL is the endpoint for fetching OAuth usage stats.
|
||||||
|
// It is a var (not const) to allow overriding in tests.
|
||||||
|
var anthropicUsageURL = "https://api.anthropic.com/api/oauth/usage"
|
||||||
|
|
||||||
|
func setAnthropicUsageURL(url string) { anthropicUsageURL = url }
|
||||||
|
|
||||||
|
type AnthropicUsage struct {
|
||||||
|
FiveHourUtilization float64
|
||||||
|
SevenDayUtilization float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchAnthropicUsage(token string) (*AnthropicUsage, error) {
|
||||||
|
req, err := http.NewRequest("GET", anthropicUsageURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Anthropic-Version", anthropicAPIVersion)
|
||||||
|
req.Header.Set("Anthropic-Beta", anthropicBetaHeader)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading usage response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if resp.StatusCode == http.StatusForbidden {
|
||||||
|
return nil, fmt.Errorf("insufficient scope: usage endpoint requires oauth scope")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("usage request failed (%d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
FiveHour struct {
|
||||||
|
Utilization float64 `json:"utilization"`
|
||||||
|
} `json:"five_hour"`
|
||||||
|
SevenDay struct {
|
||||||
|
Utilization float64 `json:"utilization"`
|
||||||
|
} `json:"seven_day"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing usage response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AnthropicUsage{
|
||||||
|
FiveHourUtilization: result.FiveHour.Utilization,
|
||||||
|
SevenDayUtilization: result.SevenDay.Utilization,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
98
pkg/auth/anthropic_usage_test.go
Normal file
98
pkg/auth/anthropic_usage_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetchAnthropicUsage_Success(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
|
||||||
|
t.Errorf("Authorization = %q, want %q", got, "Bearer test-token")
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Anthropic-Beta"); got != anthropicBetaHeader {
|
||||||
|
t.Errorf("Anthropic-Beta = %q, want %q", got, anthropicBetaHeader)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"five_hour":{"utilization":0.42},"seven_day":{"utilization":0.85}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Temporarily override the URL by using the test server
|
||||||
|
origURL := anthropicUsageURL
|
||||||
|
defer func() { setAnthropicUsageURL(origURL) }()
|
||||||
|
setAnthropicUsageURL(srv.URL)
|
||||||
|
|
||||||
|
usage, err := FetchAnthropicUsage("test-token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if usage.FiveHourUtilization != 0.42 {
|
||||||
|
t.Errorf("FiveHourUtilization = %v, want 0.42", usage.FiveHourUtilization)
|
||||||
|
}
|
||||||
|
if usage.SevenDayUtilization != 0.85 {
|
||||||
|
t.Errorf("SevenDayUtilization = %v, want 0.85", usage.SevenDayUtilization)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAnthropicUsage_Forbidden(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`{"error":"forbidden"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origURL := anthropicUsageURL
|
||||||
|
defer func() { setAnthropicUsageURL(origURL) }()
|
||||||
|
setAnthropicUsageURL(srv.URL)
|
||||||
|
|
||||||
|
_, err := FetchAnthropicUsage("test-token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 403, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "insufficient scope") {
|
||||||
|
t.Errorf("expected 'insufficient scope' error, got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAnthropicUsage_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(`internal error`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origURL := anthropicUsageURL
|
||||||
|
defer func() { setAnthropicUsageURL(origURL) }()
|
||||||
|
setAnthropicUsageURL(srv.URL)
|
||||||
|
|
||||||
|
_, err := FetchAnthropicUsage("test-token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 500, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "500") {
|
||||||
|
t.Errorf("expected error containing '500', got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAnthropicUsage_MalformedJSON(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`not json`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origURL := anthropicUsageURL
|
||||||
|
defer func() { setAnthropicUsageURL(origURL) }()
|
||||||
|
setAnthropicUsageURL(srv.URL)
|
||||||
|
|
||||||
|
_, err := FetchAnthropicUsage("test-token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for malformed JSON, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "parsing usage response") {
|
||||||
|
t.Errorf("expected 'parsing usage response' error, got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -66,7 +66,8 @@ func decodeBase64(s string) string {
|
||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateState() (string, error) {
|
// GenerateState generates a random state string for OAuth CSRF protection.
|
||||||
|
func GenerateState() (string, error) {
|
||||||
buf := make([]byte, 32)
|
buf := make([]byte, 32)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -80,7 +81,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
return nil, fmt.Errorf("generating PKCE: %w", err)
|
return nil, fmt.Errorf("generating PKCE: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
state, err := generateState()
|
state, err := GenerateState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("generating state: %w", err)
|
return nil, fmt.Errorf("generating state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -127,7 +128,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
|
|
||||||
fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL)
|
fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL)
|
||||||
|
|
||||||
if err := openBrowser(authURL); err != nil {
|
if err := OpenBrowser(authURL); err != nil {
|
||||||
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,7 +154,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
if result.err != nil {
|
if result.err != nil {
|
||||||
return nil, result.err
|
return nil, result.err
|
||||||
}
|
}
|
||||||
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
return ExchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
||||||
case manualInput := <-manualCh:
|
case manualInput := <-manualCh:
|
||||||
if manualInput == "" {
|
if manualInput == "" {
|
||||||
return nil, fmt.Errorf("manual input canceled")
|
return nil, fmt.Errorf("manual input canceled")
|
||||||
|
|
@ -169,7 +170,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
if code == "" {
|
if code == "" {
|
||||||
return nil, fmt.Errorf("could not find authorization code in input")
|
return nil, fmt.Errorf("could not find authorization code in input")
|
||||||
}
|
}
|
||||||
return exchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
|
return ExchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
|
||||||
case <-time.After(5 * time.Minute):
|
case <-time.After(5 * time.Minute):
|
||||||
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
||||||
}
|
}
|
||||||
|
|
@ -186,6 +187,62 @@ type deviceCodeResponse struct {
|
||||||
Interval int
|
Interval int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeviceCodeInfo holds the device code information returned by the OAuth provider.
|
||||||
|
type DeviceCodeInfo struct {
|
||||||
|
DeviceAuthID string `json:"device_auth_id"`
|
||||||
|
UserCode string `json:"user_code"`
|
||||||
|
VerifyURL string `json:"verify_url"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestDeviceCode requests a device code from the OAuth provider.
|
||||||
|
// Returns the info needed for the user to authenticate in a browser.
|
||||||
|
func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) {
|
||||||
|
reqBody, _ := json.Marshal(map[string]string{
|
||||||
|
"client_id": cfg.ClientID,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := http.Post(
|
||||||
|
cfg.Issuer+"/api/accounts/deviceauth/usercode",
|
||||||
|
"application/json",
|
||||||
|
strings.NewReader(string(reqBody)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("requesting device code: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading device code response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceResp, err := parseDeviceCodeResponse(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing device code response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if deviceResp.Interval < 1 {
|
||||||
|
deviceResp.Interval = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DeviceCodeInfo{
|
||||||
|
DeviceAuthID: deviceResp.DeviceAuthID,
|
||||||
|
UserCode: deviceResp.UserCode,
|
||||||
|
VerifyURL: cfg.Issuer + "/codex/device",
|
||||||
|
Interval: deviceResp.Interval,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollDeviceCodeOnce makes a single poll attempt to check if the user has authenticated.
|
||||||
|
// Returns (credential, nil) on success, (nil, nil) if still pending, or (nil, err) on failure.
|
||||||
|
func PollDeviceCodeOnce(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) {
|
||||||
|
return pollDeviceCode(cfg, deviceAuthID, userCode)
|
||||||
|
}
|
||||||
|
|
||||||
func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
|
func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
|
||||||
var raw struct {
|
var raw struct {
|
||||||
DeviceAuthID string `json:"device_auth_id"`
|
DeviceAuthID string `json:"device_auth_id"`
|
||||||
|
|
@ -246,7 +303,10 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading device code response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -306,7 +366,10 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au
|
||||||
return nil, fmt.Errorf("pending")
|
return nil, fmt.Errorf("pending")
|
||||||
}
|
}
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading device token response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var tokenResp struct {
|
var tokenResp struct {
|
||||||
AuthorizationCode string `json:"authorization_code"`
|
AuthorizationCode string `json:"authorization_code"`
|
||||||
|
|
@ -318,7 +381,7 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURI := cfg.Issuer + "/deviceauth/callback"
|
redirectURI := cfg.Issuer + "/deviceauth/callback"
|
||||||
return exchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
|
return ExchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
|
||||||
}
|
}
|
||||||
|
|
||||||
func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) {
|
func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
|
|
@ -347,7 +410,10 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading token refresh response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -410,7 +476,8 @@ func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU
|
||||||
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
|
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
|
// ExchangeCodeForTokens exchanges an authorization code for tokens.
|
||||||
|
func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
|
||||||
data := url.Values{
|
data := url.Values{
|
||||||
"grant_type": {"authorization_code"},
|
"grant_type": {"authorization_code"},
|
||||||
"code": {code},
|
"code": {code},
|
||||||
|
|
@ -439,7 +506,10 @@ func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading token exchange response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -552,7 +622,8 @@ func base64URLDecode(s string) ([]byte, error) {
|
||||||
return base64.StdEncoding.DecodeString(s)
|
return base64.StdEncoding.DecodeString(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func openBrowser(url string) error {
|
// OpenBrowser opens the given URL in the user's default browser.
|
||||||
|
func OpenBrowser(url string) error {
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
return exec.Command("open", url).Start()
|
return exec.Command("open", url).Start()
|
||||||
|
|
|
||||||
|
|
@ -219,9 +219,9 @@ func TestExchangeCodeForTokens(t *testing.T) {
|
||||||
Port: 1455,
|
Port: 1455,
|
||||||
}
|
}
|
||||||
|
|
||||||
cred, err := exchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback")
|
cred, err := ExchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("exchangeCodeForTokens() error: %v", err)
|
t.Fatalf("ExchangeCodeForTokens() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cred.AccessToken != "mock-access-token" {
|
if cred.AccessToken != "mock-access-token" {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthCredential struct {
|
type AuthCredential struct {
|
||||||
|
|
@ -37,6 +39,9 @@ func (c *AuthCredential) NeedsRefresh() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func authFilePath() string {
|
func authFilePath() string {
|
||||||
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
|
return filepath.Join(home, "auth.json")
|
||||||
|
}
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
return filepath.Join(home, ".picoclaw", "auth.json")
|
return filepath.Join(home, ".picoclaw", "auth.json")
|
||||||
}
|
}
|
||||||
|
|
@ -63,16 +68,13 @@ func LoadStore() (*AuthStore, error) {
|
||||||
|
|
||||||
func SaveStore(store *AuthStore) error {
|
func SaveStore(store *AuthStore) error {
|
||||||
path := authFilePath()
|
path := authFilePath()
|
||||||
dir := filepath.Dir(path)
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.MarshalIndent(store, "", " ")
|
data, err := json.MarshalIndent(store, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(path, data, 0o600)
|
|
||||||
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCredential(provider string) (*AuthCredential, error) {
|
func GetCredential(provider string) (*AuthCredential, error) {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,35 @@ func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LoginSetupToken(r io.Reader) (*AuthCredential, error) {
|
||||||
|
fmt.Println("Paste your setup token from `claude setup-token`:")
|
||||||
|
fmt.Print("> ")
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
if !scanner.Scan() {
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("reading token: %w", err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no input received")
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimSpace(scanner.Text())
|
||||||
|
|
||||||
|
if !strings.HasPrefix(token, "sk-ant-oat01-") {
|
||||||
|
return nil, fmt.Errorf("invalid setup token: expected prefix sk-ant-oat01-")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(token) < 80 {
|
||||||
|
return nil, fmt.Errorf("invalid setup token: too short (expected at least 80 characters)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AuthCredential{
|
||||||
|
AccessToken: token,
|
||||||
|
Provider: "anthropic",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func providerDisplayName(provider string) string {
|
func providerDisplayName(provider string) string {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
|
|
|
||||||
61
pkg/auth/token_test.go
Normal file
61
pkg/auth/token_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoginSetupToken(t *testing.T) {
|
||||||
|
// A valid token: correct prefix + at least 80 chars
|
||||||
|
validToken := "sk-ant-oat01-" + strings.Repeat("a", 80)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{"valid token", validToken, ""},
|
||||||
|
{"empty input", "", "expected prefix sk-ant-oat01-"},
|
||||||
|
{"wrong prefix", "sk-ant-api-" + strings.Repeat("a", 80), "expected prefix sk-ant-oat01-"},
|
||||||
|
{"too short", "sk-ant-oat01-short", "too short"},
|
||||||
|
{"whitespace only", " ", "expected prefix sk-ant-oat01-"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := strings.NewReader(tt.input + "\n")
|
||||||
|
cred, err := LoginSetupToken(r)
|
||||||
|
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cred.AccessToken != validToken {
|
||||||
|
t.Errorf("AccessToken = %q, want %q", cred.AccessToken, validToken)
|
||||||
|
}
|
||||||
|
if cred.Provider != "anthropic" {
|
||||||
|
t.Errorf("Provider = %q, want %q", cred.Provider, "anthropic")
|
||||||
|
}
|
||||||
|
if cred.AuthMethod != "oauth" {
|
||||||
|
t.Errorf("AuthMethod = %q, want %q", cred.AuthMethod, "oauth")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginSetupToken_EmptyReader(t *testing.T) {
|
||||||
|
r := strings.NewReader("")
|
||||||
|
_, err := LoginSetupToken(r)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty reader, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
157
pkg/bus/bus.go
157
pkg/bus/bus.go
|
|
@ -2,81 +2,156 @@ package bus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sync"
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrBusClosed is returned when publishing to a closed MessageBus.
|
||||||
|
var ErrBusClosed = errors.New("message bus closed")
|
||||||
|
|
||||||
|
const defaultBusBufferSize = 64
|
||||||
|
|
||||||
type MessageBus struct {
|
type MessageBus struct {
|
||||||
inbound chan InboundMessage
|
inbound chan InboundMessage
|
||||||
outbound chan OutboundMessage
|
outbound chan OutboundMessage
|
||||||
handlers map[string]MessageHandler
|
outboundMedia chan OutboundMediaMessage
|
||||||
closed bool
|
done chan struct{}
|
||||||
mu sync.RWMutex
|
closed atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMessageBus() *MessageBus {
|
func NewMessageBus() *MessageBus {
|
||||||
return &MessageBus{
|
return &MessageBus{
|
||||||
inbound: make(chan InboundMessage, 100),
|
inbound: make(chan InboundMessage, defaultBusBufferSize),
|
||||||
outbound: make(chan OutboundMessage, 100),
|
outbound: make(chan OutboundMessage, defaultBusBufferSize),
|
||||||
handlers: make(map[string]MessageHandler),
|
outboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize),
|
||||||
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishInbound(msg InboundMessage) {
|
func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {
|
||||||
mb.mu.RLock()
|
if mb.closed.Load() {
|
||||||
defer mb.mu.RUnlock()
|
return ErrBusClosed
|
||||||
if mb.closed {
|
}
|
||||||
return
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case mb.inbound <- msg:
|
||||||
|
return nil
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
mb.inbound <- msg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) {
|
func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) {
|
||||||
select {
|
select {
|
||||||
case msg := <-mb.inbound:
|
case msg, ok := <-mb.inbound:
|
||||||
return msg, true
|
return msg, ok
|
||||||
|
case <-mb.done:
|
||||||
|
return InboundMessage{}, false
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return InboundMessage{}, false
|
return InboundMessage{}, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishOutbound(msg OutboundMessage) {
|
func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {
|
||||||
mb.mu.RLock()
|
if mb.closed.Load() {
|
||||||
defer mb.mu.RUnlock()
|
return ErrBusClosed
|
||||||
if mb.closed {
|
}
|
||||||
return
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case mb.outbound <- msg:
|
||||||
|
return nil
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
mb.outbound <- msg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) {
|
func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) {
|
||||||
select {
|
select {
|
||||||
case msg := <-mb.outbound:
|
case msg, ok := <-mb.outbound:
|
||||||
return msg, true
|
return msg, ok
|
||||||
|
case <-mb.done:
|
||||||
|
return OutboundMessage{}, false
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return OutboundMessage{}, false
|
return OutboundMessage{}, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) RegisterHandler(channel string, handler MessageHandler) {
|
func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {
|
||||||
mb.mu.Lock()
|
if mb.closed.Load() {
|
||||||
defer mb.mu.Unlock()
|
return ErrBusClosed
|
||||||
mb.handlers[channel] = handler
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case mb.outboundMedia <- msg:
|
||||||
|
return nil
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) GetHandler(channel string) (MessageHandler, bool) {
|
func (mb *MessageBus) SubscribeOutboundMedia(ctx context.Context) (OutboundMediaMessage, bool) {
|
||||||
mb.mu.RLock()
|
select {
|
||||||
defer mb.mu.RUnlock()
|
case msg, ok := <-mb.outboundMedia:
|
||||||
handler, ok := mb.handlers[channel]
|
return msg, ok
|
||||||
return handler, ok
|
case <-mb.done:
|
||||||
|
return OutboundMediaMessage{}, false
|
||||||
|
case <-ctx.Done():
|
||||||
|
return OutboundMediaMessage{}, false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) Close() {
|
func (mb *MessageBus) Close() {
|
||||||
mb.mu.Lock()
|
if mb.closed.CompareAndSwap(false, true) {
|
||||||
defer mb.mu.Unlock()
|
close(mb.done)
|
||||||
if mb.closed {
|
|
||||||
return
|
// Drain buffered channels so messages aren't silently lost.
|
||||||
|
// Channels are NOT closed to avoid send-on-closed panics from concurrent publishers.
|
||||||
|
drained := 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mb.inbound:
|
||||||
|
drained++
|
||||||
|
default:
|
||||||
|
goto doneInbound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doneInbound:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mb.outbound:
|
||||||
|
drained++
|
||||||
|
default:
|
||||||
|
goto doneOutbound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doneOutbound:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mb.outboundMedia:
|
||||||
|
drained++
|
||||||
|
default:
|
||||||
|
goto doneMedia
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doneMedia:
|
||||||
|
if drained > 0 {
|
||||||
|
logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{
|
||||||
|
"count": drained,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
mb.closed = true
|
|
||||||
close(mb.inbound)
|
|
||||||
close(mb.outbound)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
229
pkg/bus/bus_test.go
Normal file
229
pkg/bus/bus_test.go
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
package bus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublishConsume(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
msg := InboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "hello",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishInbound(ctx, msg); err != nil {
|
||||||
|
t.Fatalf("PublishInbound failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := mb.ConsumeInbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ConsumeInbound returned ok=false")
|
||||||
|
}
|
||||||
|
if got.Content != "hello" {
|
||||||
|
t.Fatalf("expected content 'hello', got %q", got.Content)
|
||||||
|
}
|
||||||
|
if got.Channel != "test" {
|
||||||
|
t.Fatalf("expected channel 'test', got %q", got.Channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishOutboundSubscribe(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
msg := OutboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
ChatID: "123",
|
||||||
|
Content: "world",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishOutbound(ctx, msg); err != nil {
|
||||||
|
t.Fatalf("PublishOutbound failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := mb.SubscribeOutbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("SubscribeOutbound returned ok=false")
|
||||||
|
}
|
||||||
|
if got.Content != "world" {
|
||||||
|
t.Fatalf("expected content 'world', got %q", got.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInbound_ContextCancel(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
// Fill the buffer
|
||||||
|
ctx := context.Background()
|
||||||
|
for i := range defaultBusBufferSize {
|
||||||
|
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
|
||||||
|
t.Fatalf("fill failed at %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now buffer is full; publish with a canceled context
|
||||||
|
cancelCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := mb.PublishInbound(cancelCtx, InboundMessage{Content: "overflow"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error from canceled context, got nil")
|
||||||
|
}
|
||||||
|
if err != context.Canceled {
|
||||||
|
t.Fatalf("expected context.Canceled, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInbound_BusClosed(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"})
|
||||||
|
if err != ErrBusClosed {
|
||||||
|
t.Fatalf("expected ErrBusClosed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishOutbound_BusClosed(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
err := mb.PublishOutbound(context.Background(), OutboundMessage{Content: "test"})
|
||||||
|
if err != ErrBusClosed {
|
||||||
|
t.Fatalf("expected ErrBusClosed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeInbound_ContextCancel(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, ok := mb.ConsumeInbound(ctx)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected ok=false when context is canceled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeInbound_BusClosed(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, ok := mb.ConsumeInbound(ctx)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected ok=false when bus is closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscribeOutbound_BusClosed(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, ok := mb.SubscribeOutbound(ctx)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected ok=false when bus is closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentPublishClose(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const numGoroutines = 100
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(numGoroutines + 1)
|
||||||
|
|
||||||
|
// Spawn many goroutines trying to publish
|
||||||
|
for range numGoroutines {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
// Use a short timeout context so we don't block forever after close
|
||||||
|
publishCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
// Errors are expected; we just must not panic or deadlock
|
||||||
|
_ = mb.PublishInbound(publishCtx, InboundMessage{Content: "concurrent"})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close from another goroutine
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
mb.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Must complete without deadlock
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// success
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("test timed out - possible deadlock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInbound_FullBuffer(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Fill the buffer
|
||||||
|
for i := range defaultBusBufferSize {
|
||||||
|
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
|
||||||
|
t.Fatalf("fill failed at %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer is full; publish with short timeout
|
||||||
|
timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := mb.PublishInbound(timeoutCtx, InboundMessage{Content: "overflow"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when buffer is full and context times out")
|
||||||
|
}
|
||||||
|
if err != context.DeadlineExceeded {
|
||||||
|
t.Fatalf("expected context.DeadlineExceeded, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseIdempotent(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
|
||||||
|
// Multiple Close calls must not panic
|
||||||
|
mb.Close()
|
||||||
|
mb.Close()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
// After close, publish should return ErrBusClosed
|
||||||
|
err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"})
|
||||||
|
if err != ErrBusClosed {
|
||||||
|
t.Fatalf("expected ErrBusClosed after multiple closes, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,30 @@
|
||||||
package bus
|
package bus
|
||||||
|
|
||||||
|
// Peer identifies the routing peer for a message (direct, group, channel, etc.)
|
||||||
|
type Peer struct {
|
||||||
|
Kind string `json:"kind"` // "direct" | "group" | "channel" | ""
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SenderInfo provides structured sender identity information.
|
||||||
|
type SenderInfo struct {
|
||||||
|
Platform string `json:"platform,omitempty"` // "telegram", "discord", "slack", ...
|
||||||
|
PlatformID string `json:"platform_id,omitempty"` // raw platform ID, e.g. "123456"
|
||||||
|
CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" format
|
||||||
|
Username string `json:"username,omitempty"` // username (e.g. @alice)
|
||||||
|
DisplayName string `json:"display_name,omitempty"` // display name
|
||||||
|
}
|
||||||
|
|
||||||
type InboundMessage struct {
|
type InboundMessage struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
SenderID string `json:"sender_id"`
|
SenderID string `json:"sender_id"`
|
||||||
|
Sender SenderInfo `json:"sender"`
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Media []string `json:"media,omitempty"`
|
Media []string `json:"media,omitempty"`
|
||||||
|
Peer Peer `json:"peer"` // routing peer
|
||||||
|
MessageID string `json:"message_id,omitempty"` // platform message ID
|
||||||
|
MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope
|
||||||
SessionKey string `json:"session_key"`
|
SessionKey string `json:"session_key"`
|
||||||
Metadata map[string]string `json:"metadata,omitempty"`
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -16,4 +35,18 @@ type OutboundMessage struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageHandler func(InboundMessage) error
|
// MediaPart describes a single media attachment to send.
|
||||||
|
type MediaPart struct {
|
||||||
|
Type string `json:"type"` // "image" | "audio" | "video" | "file"
|
||||||
|
Ref string `json:"ref"` // media store ref, e.g. "media://abc123"
|
||||||
|
Caption string `json:"caption,omitempty"` // optional caption text
|
||||||
|
Filename string `json:"filename,omitempty"` // original filename hint
|
||||||
|
ContentType string `json:"content_type,omitempty"` // MIME type hint
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutboundMediaMessage carries media attachments from Agent to channels via the bus.
|
||||||
|
type OutboundMediaMessage struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
Parts []MediaPart `json:"parts"`
|
||||||
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue